refactor: 移除Cunkebao项目中未使用的微信聊天相关组件和代码

重构Touchkebao项目中的微信API调用逻辑,优化消息记录处理
This commit is contained in:
超级老白兔
2025-09-15 18:03:14 +08:00
parent d802b9da1f
commit c88431b564
50 changed files with 79 additions and 11341 deletions

View File

@@ -1,246 +0,0 @@
import request from "@/api/request2";
import {
MessageData,
ChatHistoryResponse,
MessageType,
OnlineStatus,
MessageStatus,
FileUploadResponse,
EmojiData,
QuickReply,
ChatSettings,
} from "./data";
//读取聊天信息
//kf.quwanzhi.com:9991/api/WechatFriend/clearUnreadCount
export function WechatGroup(params) {
return request("/api/WechatGroup/list", params, "GET");
}
//获取聊天记录-1 清除未读
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 getChatMessages(params: {
wechatAccountId: number;
wechatFriendId?: number;
wechatChatroomId?: number;
From: number;
To: number;
Count: number;
olderData: boolean;
}) {
return request("/api/FriendMessage/SearchMessage", params, "GET");
}
export function getChatroomMessages(params: {
wechatAccountId: number;
wechatFriendId?: number;
wechatChatroomId?: number;
From: number;
To: number;
Count: number;
olderData: boolean;
}) {
return request("/api/ChatroomMessage/SearchMessage", params, "GET");
}
//获取群列表
export function getGroupList(params: { prevId: number; count: number }) {
return request(
"/api/wechatChatroom/listExcludeMembersByPage?",
params,
"GET",
);
}
//获取群成员
export function getGroupMembers(params: { id: number }) {
return request(
"/api/WechatChatroom/listMembersByWechatChatroomId",
params,
"GET",
);
}
//触客宝登陆
export function loginWithToken(params: any) {
return request(
"/token",
params,
"POST",
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
1000,
);
}
// 获取触客宝用户信息
export function getChuKeBaoUserInfo() {
return request("/api/account/self", {}, "GET");
}
// 获取联系人列表
export const getContactList = (params: { prevId: number; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};
//获取控制终端列表
export const getControlTerminalList = params => {
return request("/api/wechataccount", params, "GET");
};
// 获取聊天历史
export const getChatHistory = (
chatId: string,
page: number = 1,
pageSize: number = 50,
): Promise<ChatHistoryResponse> => {
return request(`/v1/chats/${chatId}/messages`, { page, pageSize }, "GET");
};
// 发送消息
export const sendMessage = (
chatId: string,
content: string,
type: MessageType = MessageType.TEXT,
): Promise<MessageData> => {
return request(`/v1/chats/${chatId}/messages`, { content, type }, "POST");
};
// 发送文件消息
export const sendFileMessage = (
chatId: string,
file: File,
type: MessageType,
): Promise<MessageData> => {
const formData = new FormData();
formData.append("file", file);
formData.append("type", type);
return request(`/v1/chats/${chatId}/messages/file`, formData, "POST");
};
// 标记消息为已读
export const markMessageAsRead = (messageId: string): Promise<void> => {
return request(`/v1/messages/${messageId}/read`, {}, "PUT");
};
// 标记聊天为已读
export const markChatAsRead = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/read`, {}, "PUT");
};
// 添加群组成员
export const addGroupMembers = (
groupId: string,
memberIds: string[],
): Promise<void> => {
return request(`/v1/groups/${groupId}/members`, { memberIds }, "POST");
};
// 移除群组成员
export const removeGroupMembers = (
groupId: string,
memberIds: string[],
): Promise<void> => {
return request(`/v1/groups/${groupId}/members`, { memberIds }, "DELETE");
};
// 获取在线状态
export const getOnlineStatus = (userId: string): Promise<OnlineStatus> => {
return request(`/v1/users/${userId}/status`, {}, "GET");
};
// 获取消息状态
export const getMessageStatus = (messageId: string): Promise<MessageStatus> => {
return request(`/v1/messages/${messageId}/status`, {}, "GET");
};
// 上传文件
export const uploadFile = (file: File): Promise<FileUploadResponse> => {
const formData = new FormData();
formData.append("file", file);
return request("/v1/upload", formData, "POST");
};
// 获取表情包列表
export const getEmojiList = (): Promise<EmojiData[]> => {
return request("/v1/emojis", {}, "GET");
};
// 获取快捷回复列表
export const getQuickReplies = (): Promise<QuickReply[]> => {
return request("/v1/quick-replies", {}, "GET");
};
// 添加快捷回复
export const addQuickReply = (data: {
content: string;
category: string;
}): Promise<QuickReply> => {
return request("/v1/quick-replies", data, "POST");
};
// 删除快捷回复
export const deleteQuickReply = (id: string): Promise<void> => {
return request(`/v1/quick-replies/${id}`, {}, "DELETE");
};
// 获取聊天设置
export const getChatSettings = (): Promise<ChatSettings> => {
return request("/v1/chat/settings", {}, "GET");
};
// 更新聊天设置
export const updateChatSettings = (
settings: Partial<ChatSettings>,
): Promise<ChatSettings> => {
return request("/v1/chat/settings", settings, "PUT");
};
// 删除聊天会话
export const deleteChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}`, {}, "DELETE");
};
// 置顶聊天会话
export const pinChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/pin`, {}, "PUT");
};
// 取消置顶聊天会话
export const unpinChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/unpin`, {}, "PUT");
};
// 静音聊天会话
export const muteChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/mute`, {}, "PUT");
};
// 取消静音聊天会话
export const unmuteChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/unmute`, {}, "PUT");
};
// 转发消息
export const forwardMessage = (
messageId: string,
targetChatIds: string[],
): Promise<void> => {
return request("/v1/messages/forward", { messageId, targetChatIds }, "POST");
};
// 撤回消息
export const recallMessage = (messageId: string): Promise<void> => {
return request(`/v1/messages/${messageId}/recall`, {}, "PUT");
};

View File

@@ -1,44 +0,0 @@
// 菜单项接口
export interface MenuItem {
id: string;
title: string;
icon: string;
path?: string;
}
// 菜单列表数据
export const menuList: MenuItem[] = [
{
id: "dashboard",
title: "数据面板",
icon: "📊",
path: "/ckbox/dashboard",
},
{
id: "wechat",
title: "微信管理",
icon: "💬",
path: "/ckbox/weChat",
},
];
// 抽屉菜单配置数据
export const drawerMenuData = {
header: {
logoIcon: "✨",
appName: "触客宝",
appDesc: "AI智能营销系统",
},
primaryButton: {
title: "AI智能客服",
icon: "🔒",
},
footer: {
balanceIcon: "⚡",
balanceLabel: "算力余额",
balanceValue: "9307.423",
},
};
// 导出默认配置
export default drawerMenuData;

View File

@@ -1,316 +0,0 @@
.header {
background: #fff;
padding: 0 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
border-bottom: 1px solid #f0f0f0;
}
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
}
.menuButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 6px;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
}
.anticon {
font-size: 18px;
color: #666;
}
}
.title {
font-size: 18px;
color: #333;
margin: 0;
}
.headerRight {
display: flex;
align-items: center;
gap: 16px;
}
.userInfo {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
.suanli {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
font-weight: 500;
padding: 6px 12px;
background: #f8f9fa;
border-radius: 20px;
border: 1px solid #e9ecef;
.suanliIcon {
font-size: 16px;
color: #ffc107;
}
}
}
.messageButton {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
transition: all 0.3s;
border: 1px solid #e9ecef;
background: #fff;
&:hover {
background-color: #f8f9fa;
border-color: #1890ff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
}
.anticon {
font-size: 18px;
color: #666;
}
}
.userSection {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 8px 16px;
border-radius: 24px;
transition: all 0.3s;
border: 1px solid #e9ecef;
background: #fff;
&:hover {
background-color: #f8f9fa;
border-color: #1890ff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
}
}
.userNickname {
font-size: 14px;
color: #333;
font-weight: 600;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar {
border: 2px solid #e9ecef;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.username {
font-size: 14px;
color: #333;
font-weight: 500;
margin-left: 8px;
}
// 抽屉样式
.drawer {
:global(.ant-drawer-header) {
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
:global(.ant-drawer-body) {
padding: 0;
}
}
.drawerContent {
height: 100%;
display: flex;
flex-direction: column;
background: #f8f9fa;
}
.drawerHeader {
padding: 20px;
background: #fff;
border-bottom: none;
}
.logoSection {
display: flex;
align-items: center;
gap: 12px;
}
.logoIcon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.logoText {
display: flex;
flex-direction: column;
}
.appName {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.appDesc {
font-size: 12px;
color: #999;
margin: 2px 0 0 0;
}
.drawerBody {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.primaryButton {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.buttonIcon {
font-size: 20px;
}
span {
font-size: 16px;
font-weight: 600;
color: white;
}
}
.menuSection {
margin-top: 8px;
}
.menuItem {
display: flex;
align-items: center;
padding: 16px 20px;
cursor: pointer;
transition: all 0.3s;
border-radius: 8px;
&:hover {
background-color: #f0f0f0;
}
span {
font-size: 16px;
color: #333;
font-weight: 500;
}
}
.menuIcon {
font-size: 20px;
margin-right: 12px;
width: 20px;
text-align: center;
}
.drawerFooter {
padding: 20px;
background: #fff;
border-top: 1px solid #f0f0f0;
.balanceSection {
display: flex;
justify-content: space-between;
align-items: center;
.balanceIcon {
color: #666;
.suanliIcon {
font-size: 20px;
}
}
.balanceText {
color: #3d9c0d;
}
}
}
.balanceLabel {
font-size: 12px;
color: #999;
margin: 0;
}
.balanceAmount {
font-size: 18px;
font-weight: 600;
color: #52c41a;
margin: 2px 0 0 0;
}
// 响应式设计
@media (max-width: 768px) {
.header {
padding: 0 12px;
}
.title {
font-size: 16px;
}
.username {
display: none;
}
.drawer {
width: 280px !important;
}
}

View File

@@ -1,143 +0,0 @@
import React, { useState } from "react";
import { Layout, Drawer, Avatar, Dropdown, Space, Button } from "antd";
import {
MenuOutlined,
UserOutlined,
LogoutOutlined,
SettingOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { useNavigate } from "react-router-dom";
import { drawerMenuData, menuList } from "./index.data";
import styles from "./index.module.scss";
const { Header } = Layout;
interface NavCommonProps {
title?: string;
onMenuClick?: () => void;
}
const NavCommon: React.FC<NavCommonProps> = ({
title = "触客宝",
onMenuClick,
}) => {
const [drawerVisible, setDrawerVisible] = useState(false);
const navigate = useNavigate();
const { userInfo } = useCkChatStore();
// 处理菜单图标点击
const handleMenuClick = () => {
setDrawerVisible(true);
onMenuClick?.();
};
// 处理抽屉关闭
const handleDrawerClose = () => {
setDrawerVisible(false);
};
// 默认抽屉内容
const defaultDrawerContent = (
<div className={styles.drawerContent}>
<div className={styles.drawerHeader}>
<div className={styles.logoSection}>
<div className={styles.logoIcon}>
{drawerMenuData.header.logoIcon}
</div>
<div className={styles.logoText}>
<div className={styles.appName}>
{drawerMenuData.header.appName}
</div>
<div className={styles.appDesc}>
{drawerMenuData.header.appDesc}
</div>
</div>
</div>
</div>
<div className={styles.drawerBody}>
<div className={styles.primaryButton}>
<div className={styles.buttonIcon}>
{drawerMenuData.primaryButton.icon}
</div>
<span>{drawerMenuData.primaryButton.title}</span>
</div>
<div className={styles.menuSection}>
{menuList.map((item, index) => (
<div
key={index}
className={styles.menuItem}
onClick={() => {
if (item.path) {
navigate(item.path);
setDrawerVisible(false);
}
}}
>
<div className={styles.menuIcon}>{item.icon}</div>
<span>{item.title}</span>
</div>
))}
</div>
</div>
<div className={styles.drawerFooter}>
<div className={styles.balanceSection}>
<div className={styles.balanceIcon}>
<span className={styles.suanliIcon}>
{drawerMenuData.footer.balanceIcon}
</span>
{drawerMenuData.footer.balanceLabel}
</div>
<div className={styles.balanceText}>
{drawerMenuData.footer.balanceValue}
</div>
</div>
</div>
</div>
);
return (
<>
<Header className={styles.header}>
<div className={styles.headerLeft}>
<Button
type="text"
icon={<MenuOutlined />}
onClick={handleMenuClick}
className={styles.menuButton}
/>
<span className={styles.title}>{title}</span>
</div>
<div className={styles.headerRight}>
<Space className={styles.userInfo}>
<span className={styles.suanli}>
<span className={styles.suanliIcon}></span>
9307.423
</span>
<Avatar
size={40}
icon={<UserOutlined />}
src={userInfo?.account?.avatar}
className={styles.avatar}
/>
</Space>
</div>
</Header>
<Drawer
title="菜单"
placement="left"
onClose={handleDrawerClose}
open={drawerVisible}
width={300}
className={styles.drawer}
>
{defaultDrawerContent}
</Drawer>
</>
);
};
export default NavCommon;

View File

@@ -1,193 +0,0 @@
.monitoring {
padding: 24px;
background-color: #f5f5f5;
min-height: 100vh;
.header {
margin-bottom: 24px;
h2 {
margin: 0 0 8px 0;
color: #262626;
font-size: 24px;
font-weight: 600;
}
p {
margin: 0;
color: #8c8c8c;
font-size: 14px;
}
}
.statsRow {
margin-bottom: 24px;
.statCard {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
}
}
.progressRow {
margin-bottom: 24px;
.progressCard,
.metricsCard {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
height: 280px;
.ant-card-body {
height: calc(100% - 57px);
display: flex;
flex-direction: column;
justify-content: space-between;
}
}
.progressItem {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
span {
display: block;
margin-bottom: 8px;
color: #595959;
font-size: 14px;
}
}
.metricItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
span {
color: #595959;
font-size: 14px;
}
.metricValue {
display: flex;
align-items: center;
gap: 8px;
span {
font-weight: 600;
font-size: 16px;
color: #262626;
}
}
}
}
.chartsRow {
margin-top: 24px;
}
.chartCard {
.ant-card-head {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
.ant-card-head-title {
color: white;
font-weight: 600;
}
}
.ant-card-body {
padding: 20px;
}
// 图表容器样式
.g2-tooltip {
background: rgba(0, 0, 0, 0.8);
border-radius: 6px;
color: white;
}
}
.tableRow {
margin-top: 24px;
}
.tableCard {
.ant-card-head {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
.ant-card-head-title {
color: white;
}
}
}
.tableCard {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.ant-table {
.ant-table-thead > tr > th {
background-color: #fafafa;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
}
.ant-table-tbody > tr {
&:hover {
background-color: #f5f5f5;
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.monitoring {
padding: 16px;
.header {
h2 {
font-size: 20px;
}
}
.progressRow {
.progressCard,
.metricsCard {
height: auto;
margin-bottom: 16px;
}
}
}
}
@media (max-width: 576px) {
.monitoring {
padding: 12px;
.statsRow {
.statCard {
margin-bottom: 12px;
}
}
}
}

View File

@@ -1,476 +0,0 @@
import React from "react";
import { Card, Row, Col, Statistic, Progress, Table, Tag } from "antd";
import {
UserOutlined,
MessageOutlined,
TeamOutlined,
TrophyOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from "@ant-design/icons";
import * as echarts from "echarts";
import ReactECharts from "echarts-for-react";
import styles from "./index.module.scss";
interface DashboardProps {
// 预留接口属性
}
const Dashboard: React.FC<DashboardProps> = () => {
// 模拟数据
const statsData = [
{
title: "在线设备数",
value: 128,
prefix: <UserOutlined />,
suffix: "台",
valueStyle: { color: "#3f8600" },
},
{
title: "今日消息量",
value: 2456,
prefix: <MessageOutlined />,
suffix: "条",
valueStyle: { color: "#1890ff" },
},
{
title: "活跃群组",
value: 89,
prefix: <TeamOutlined />,
suffix: "个",
valueStyle: { color: "#722ed1" },
},
{
title: "成功率",
value: 98.5,
prefix: <TrophyOutlined />,
suffix: "%",
valueStyle: { color: "#f5222d" },
},
];
const tableColumns = [
{
title: "设备名称",
dataIndex: "deviceName",
key: "deviceName",
},
{
title: "状态",
dataIndex: "status",
key: "status",
render: (status: string) => (
<Tag color={status === "在线" ? "green" : "red"}>{status}</Tag>
),
},
{
title: "消息数",
dataIndex: "messageCount",
key: "messageCount",
},
{
title: "最后活跃时间",
dataIndex: "lastActive",
key: "lastActive",
},
];
const tableData = [
{
key: "1",
deviceName: "设备001",
status: "在线",
messageCount: 245,
lastActive: "2024-01-15 14:30:25",
},
{
key: "2",
deviceName: "设备002",
status: "离线",
messageCount: 156,
lastActive: "2024-01-15 12:15:10",
},
{
key: "3",
deviceName: "设备003",
status: "在线",
messageCount: 389,
lastActive: "2024-01-15 14:28:45",
},
];
// 图表数据
const lineData = [
{ time: "00:00", value: 120 },
{ time: "02:00", value: 132 },
{ time: "04:00", value: 101 },
{ time: "06:00", value: 134 },
{ time: "08:00", value: 190 },
{ time: "10:00", value: 230 },
{ time: "12:00", value: 210 },
{ time: "14:00", value: 220 },
{ time: "16:00", value: 165 },
{ time: "18:00", value: 127 },
{ time: "20:00", value: 82 },
{ time: "22:00", value: 91 },
];
const columnData = [
{ type: "消息发送", value: 27 },
{ type: "消息接收", value: 25 },
{ type: "群组管理", value: 18 },
{ type: "设备监控", value: 15 },
{ type: "数据同步", value: 10 },
{ type: "其他", value: 5 },
];
const pieData = [
{ type: "在线设备", value: 128 },
{ type: "离线设备", value: 32 },
{ type: "维护中", value: 8 },
];
const areaData = [
{ time: "1月", value: 3000 },
{ time: "2月", value: 4000 },
{ time: "3月", value: 3500 },
{ time: "4月", value: 5000 },
{ time: "5月", value: 4900 },
{ time: "6月", value: 6000 },
];
// ECharts配置
const lineOption = {
title: {
text: "24小时消息趋势",
left: "center",
textStyle: {
color: "#333",
fontSize: 16,
},
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0,0,0,0.8)",
textStyle: {
color: "#fff",
},
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: {
type: "category",
data: lineData.map(item => item.time),
axisLine: {
lineStyle: {
color: "#ccc",
},
},
},
yAxis: {
type: "value",
axisLine: {
lineStyle: {
color: "#ccc",
},
},
},
series: [
{
data: lineData.map(item => item.value),
type: "line",
smooth: true,
lineStyle: {
color: "#1890ff",
width: 3,
},
itemStyle: {
color: "#1890ff",
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(24, 144, 255, 0.3)" },
{ offset: 1, color: "rgba(24, 144, 255, 0.1)" },
]),
},
},
],
};
const columnOption = {
title: {
text: "功能使用分布",
left: "center",
textStyle: {
color: "#333",
fontSize: 16,
},
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0,0,0,0.8)",
textStyle: {
color: "#fff",
},
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: {
type: "category",
data: columnData.map(item => item.type),
axisLabel: {
rotate: 45,
},
axisLine: {
lineStyle: {
color: "#ccc",
},
},
},
yAxis: {
type: "value",
axisLine: {
lineStyle: {
color: "#ccc",
},
},
},
series: [
{
data: columnData.map(item => item.value),
type: "bar",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#52c41a" },
{ offset: 1, color: "#389e0d" },
]),
},
},
],
};
const pieOption = {
title: {
text: "设备状态分布",
left: "center",
textStyle: {
color: "#333",
fontSize: 16,
},
},
tooltip: {
trigger: "item",
backgroundColor: "rgba(0,0,0,0.8)",
textStyle: {
color: "#fff",
},
},
legend: {
orient: "vertical",
left: "left",
},
series: [
{
name: "设备状态",
type: "pie",
radius: "50%",
data: pieData.map(item => ({ name: item.type, value: item.value })),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
itemStyle: {
borderRadius: 5,
borderColor: "#fff",
borderWidth: 2,
},
},
],
};
const areaOption = {
title: {
text: "月度数据趋势",
left: "center",
textStyle: {
color: "#333",
fontSize: 16,
},
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0,0,0,0.8)",
textStyle: {
color: "#fff",
},
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: {
type: "category",
data: areaData.map(item => item.time),
axisLine: {
lineStyle: {
color: "#ccc",
},
},
},
yAxis: {
type: "value",
axisLine: {
lineStyle: {
color: "#ccc",
},
},
},
series: [
{
data: areaData.map(item => item.value),
type: "line",
smooth: true,
lineStyle: {
color: "#722ed1",
width: 3,
},
itemStyle: {
color: "#722ed1",
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(114, 46, 209, 0.6)" },
{ offset: 1, color: "rgba(114, 46, 209, 0.1)" },
]),
},
},
],
};
return (
<div className={styles.monitoring}>
<div className={styles.header}>
<h2></h2>
<p></p>
</div>
{/* 统计卡片 */}
<Row gutter={[16, 16]} className={styles.statsRow}>
{statsData.map((stat, index) => (
<Col xs={24} sm={12} md={6} key={index}>
<Card className={styles.statCard}>
<Statistic
title={stat.title}
value={stat.value}
prefix={stat.prefix}
suffix={stat.suffix}
valueStyle={stat.valueStyle}
/>
</Card>
</Col>
))}
</Row>
{/* 进度指标 */}
<Row gutter={[16, 16]} className={styles.progressRow}>
<Col xs={24} md={12}>
<Card title="系统负载" className={styles.progressCard}>
<div className={styles.progressItem}>
<span>CPU使用率</span>
<Progress percent={65} status="active" />
</div>
<div className={styles.progressItem}>
<span>使</span>
<Progress percent={45} />
</div>
<div className={styles.progressItem}>
<span>使</span>
<Progress percent={30} />
</div>
</Card>
</Col>
<Col xs={24} md={12}>
<Card title="实时指标" className={styles.metricsCard}>
<div className={styles.metricItem}>
<span></span>
<div className={styles.metricValue}>
<span>1,245</span>
<ArrowUpOutlined style={{ color: "#3f8600" }} />
</div>
</div>
<div className={styles.metricItem}>
<span></span>
<div className={styles.metricValue}>
<span>0.2%</span>
<ArrowDownOutlined style={{ color: "#3f8600" }} />
</div>
</div>
<div className={styles.metricItem}>
<span></span>
<div className={styles.metricValue}>
<span>125ms</span>
<ArrowDownOutlined style={{ color: "#3f8600" }} />
</div>
</div>
</Card>
</Col>
</Row>
{/* 图表区域 */}
<Row gutter={[16, 16]} className={styles.chartsRow}>
<Col xs={24} lg={12}>
<Card className={styles.chartCard}>
<ReactECharts option={lineOption} style={{ height: "350px" }} />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card className={styles.chartCard}>
<ReactECharts option={columnOption} style={{ height: "350px" }} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className={styles.chartsRow}>
<Col xs={24} lg={12}>
<Card className={styles.chartCard}>
<ReactECharts option={pieOption} style={{ height: "350px" }} />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card className={styles.chartCard}>
<ReactECharts option={areaOption} style={{ height: "350px" }} />
</Card>
</Col>
</Row>
{/* 设备状态表格 */}
<Row className={styles.tableRow}>
<Col span={24}>
<Card title="设备状态" className={styles.tableCard}>
<Table
columns={tableColumns}
dataSource={tableData}
pagination={false}
size="middle"
/>
</Card>
</Col>
</Row>
</div>
);
};
export default Dashboard;

View File

@@ -1,323 +0,0 @@
// 消息列表数据接口 - 支持weChatGroup和contracts两种数据类型
export interface MessageListData {
serverId: number | string; // 服务器ID作为主键
id?: number; // 接口数据的原始ID字段
// 数据类型标识
dataType: "weChatGroup" | "contracts"; // 数据类型:微信群组或联系人
// 通用字段(两种类型都有的字段)
wechatAccountId: number; // 微信账号ID
tenantId: number; // 租户ID
accountId: number; // 账号ID
nickname: string; // 昵称
avatar?: string; // 头像
groupId: number; // 分组ID
config?: {
chat: boolean;
}; // 配置信息
labels?: string[]; // 标签列表
unreadCount: number; // 未读消息数
// 联系人特有字段当dataType为'contracts'时使用)
wechatId?: string; // 微信ID
alias?: string; // 别名
conRemark?: string; // 备注
quanPin?: string; // 全拼
gender?: number; // 性别
region?: string; // 地区
addFrom?: number; // 添加来源
phone?: string; // 电话
signature?: string; // 签名
extendFields?: any; // 扩展字段
city?: string; // 城市
lastUpdateTime?: string; // 最后更新时间
isPassed?: boolean; // 是否通过
thirdParty?: any; // 第三方
additionalPicture?: string; // 附加图片
desc?: string; // 描述
lastMessageTime?: number; // 最后消息时间
duplicate?: boolean; // 是否重复
// 微信群组特有字段当dataType为'weChatGroup'时使用)
chatroomId?: string; // 群聊ID
chatroomOwner?: string; // 群主
chatroomAvatar?: string; // 群头像
notice?: string; // 群公告
selfDisplyName?: string; // 自己在群里的显示名称
[key: string]: any; // 兼容其他字段
}
//联系人标签分组
export interface ContactGroupByLabel {
id: number;
accountId?: number;
groupName: string;
tenantId?: number;
count: number;
[key: string]: any;
}
//终端用户数据接口
export interface KfUserListData {
id: number;
tenantId: number;
wechatId: string;
nickname: string;
alias: string;
avatar: string;
gender: number;
region: string;
signature: string;
bindQQ: string;
bindEmail: string;
bindMobile: string;
createTime: string;
currentDeviceId: number;
isDeleted: boolean;
deleteTime: string;
groupId: number;
memo: string;
wechatVersion: string;
labels: string[];
lastUpdateTime: string;
isOnline?: boolean;
[key: string]: any;
}
// 账户信息接口
export interface CkAccount {
id: number;
realName: string;
nickname: string | null;
memo: string | null;
avatar: string;
userName: string;
secret: string;
accountType: number;
departmentId: number;
useGoogleSecretKey: boolean;
hasVerifyGoogleSecret: boolean;
}
//群聊数据接口
export interface weChatGroup {
id?: number;
wechatAccountId: number;
tenantId: number;
accountId: number;
chatroomId: string;
chatroomOwner: string;
conRemark: string;
nickname: string;
chatroomAvatar: string;
groupId: number;
config?: {
chat: boolean;
};
labels?: string[];
unreadCount: number;
notice: string;
selfDisplyName: string;
wechatChatroomId: number;
serverId?: number;
[key: string]: any;
}
// 联系人数据接口
export interface ContractData {
id?: number;
serverId?: number;
wechatAccountId: number;
wechatId: string;
alias: string;
conRemark: string;
nickname: string;
quanPin: string;
avatar?: string;
gender: number;
region: string;
addFrom: number;
phone: string;
labels: string[];
signature: string;
accountId: number;
extendFields: null;
city?: string;
lastUpdateTime: string;
isPassed: boolean;
tenantId: number;
groupId: number;
thirdParty: null;
additionalPicture: string;
desc: string;
config?: {
chat: boolean;
};
lastMessageTime: number;
unreadCount: number;
duplicate: boolean;
[key: string]: any;
}
//聊天记录接口
export interface ChatRecord {
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;
sender?: {
chatroomNickname: string;
isAdmin: boolean;
isDeleted: boolean;
nickname: string;
ownerWechatId: string;
wechatId: string;
[key: string]: any;
};
[key: string]: any;
}
/**
* 微信好友基本信息接口
* 包含主要字段和兼容性字段
*/
export interface WechatFriend {
// 主要字段
id: number; // 好友ID
wechatAccountId: number; // 微信账号ID
wechatId: string; // 微信ID
nickname: string; // 昵称
conRemark: string; // 备注名
avatar: string; // 头像URL
gender: number; // 性别1-男2-女0-未知
region: string; // 地区
phone: string; // 电话
labels: string[]; // 标签列表
[key: string]: any;
}
// 消息类型枚举
export enum MessageType {
TEXT = "text",
IMAGE = "image",
VOICE = "voice",
VIDEO = "video",
FILE = "file",
LOCATION = "location",
}
// 消息数据接口
export interface MessageData {
id: string;
senderId: string;
senderName: string;
content: string;
type: MessageType;
timestamp: string;
isRead: boolean;
replyTo?: string;
forwardFrom?: string;
}
// 聊天会话类型
export type ChatType = "private" | "group";
// 聊天会话接口
export interface ChatSession {
id: string;
type: ChatType;
name: string;
avatar?: string;
lastMessage: string;
lastTime: string;
unreadCount: number;
online: boolean;
members?: string[];
pinned?: boolean;
muted?: boolean;
}
// 聊天历史响应接口
export interface ChatHistoryResponse {
messages: MessageData[];
hasMore: boolean;
total: number;
}
// 发送消息请求接口
export interface SendMessageRequest {
chatId: string;
content: string;
type: MessageType;
replyTo?: string;
}
// 搜索联系人请求接口
export interface SearchContactRequest {
keyword: string;
limit?: number;
}
// 在线状态接口
export interface OnlineStatus {
userId: string;
online: boolean;
lastSeen: string;
}
// 消息状态接口
export interface MessageStatus {
messageId: string;
status: "sending" | "sent" | "delivered" | "read" | "failed";
timestamp: string;
}
// 文件上传响应接口
export interface FileUploadResponse {
url: string;
filename: string;
size: number;
type: string;
}
// 表情包接口
export interface EmojiData {
id: string;
name: string;
url: string;
category: string;
}
// 快捷回复接口
export interface QuickReply {
id: string;
content: string;
category: string;
useCount: number;
}
// 聊天设置接口
export interface ChatSettings {
autoReply: boolean;
autoReplyMessage: string;
notification: boolean;
sound: boolean;
theme: "light" | "dark";
fontSize: "small" | "medium" | "large";
}

View File

@@ -1,198 +0,0 @@
.ckboxLayout {
height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
.header {
background: #1890ff;
color: #fff;
height: 64px;
line-height: 64px;
padding: 0 24px;
font-size: 18px;
font-weight: bold;
}
.verticalSider {
background: #2e2e2e;
border-right: 1px solid #f0f0f0;
overflow: hidden;
}
.sider {
background: #fff;
border-right: 1px solid #f0f0f0;
overflow: auto;
}
.sidebar {
height: 100%;
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 16px;
margin: 0;
&:hover {
color: #1890ff;
}
&.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: #1890ff;
}
}
}
:global(.ant-tabs-ink-bar) {
background: #1890ff;
}
}
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #8c8c8c;
p {
margin-top: 16px;
font-size: 14px;
}
}
}
.mainContent {
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow: auto;
.chatContainer {
height: 100%;
display: flex;
flex-direction: column;
.chatToolbar {
background: #fff;
border-bottom: 1px solid #f0f0f0;
padding: 8px 16px;
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 48px;
}
}
.welcomeScreen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
.welcomeContent {
text-align: center;
color: #8c8c8c;
h2 {
margin: 24px 0 12px 0;
color: #262626;
font-size: 24px;
font-weight: 600;
}
p {
font-size: 16px;
margin: 0;
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.ckboxLayout {
.sidebar {
.searchBar {
padding: 12px;
}
.tabs {
:global(.ant-tabs-nav) {
padding: 0 12px;
:global(.ant-tabs-tab) {
padding: 10px 12px;
}
}
}
}
.mainContent {
.chatContainer {
.chatToolbar {
padding: 6px 12px;
min-height: 40px;
}
}
.welcomeScreen {
.welcomeContent {
h2 {
font-size: 20px;
}
p {
font-size: 14px;
}
}
}
}
}
}

View File

@@ -1,18 +0,0 @@
import React from "react";
import Layout from "@/components/Layout/Layout";
import { Outlet } from "react-router-dom";
import NavCommon from "./components/NavCommon";
import styles from "./index.module.scss";
const CkboxPage: React.FC = () => {
return (
<Layout
header={
<NavCommon title="AI自动聊天懂业务会引导客户不停地聊不停" />
}
>
<Outlet />
</Layout>
);
};
export default CkboxPage;

View File

@@ -1,307 +0,0 @@
import {
asyncKfUserList,
asyncContractList,
asyncChatSessions,
asyncWeChatGroup,
asyncCountLables,
useCkChatStore,
} from "@/store/module/ckchat/ckchat";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import {
loginWithToken,
getControlTerminalList,
getContactList,
getGroupList,
} from "./api";
import { useUserStore } from "@/store/module/user";
import {
KfUserListData,
ContractData,
weChatGroup,
} from "@/pages/pc/ckbox/data";
import { WechatGroup } from "./api";
const { login2 } = useUserStore.getState();
//获取触客宝基础信息
export const chatInitAPIdata = async () => {
try {
//获取联系人列表
const contractList = await getAllContactList();
//获取联系人列表
asyncContractList(contractList);
//获取群列表
const groupList = await getAllGroupList();
await asyncWeChatGroup(groupList);
// 提取不重复的wechatAccountId组
const uniqueWechatAccountIds: number[] = getUniqueWechatAccountIds(
contractList,
groupList,
);
//获取控制终端列表
const kfUserList: KfUserListData[] =
await getControlTerminalListByWechatAccountIds(uniqueWechatAccountIds);
//获取用户列表
await asyncKfUserList(kfUserList);
//获取标签列表
const countLables = await getCountLables();
await asyncCountLables(countLables);
//获取消息会话列表并按lastUpdateTime排序
const filterUserSessions = contractList?.filter(
v => v?.config && v.config?.chat,
);
const filterGroupSessions = groupList?.filter(
v => v?.config && v.config?.chat,
);
//排序功能
const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
(a, b) => {
// 如果lastUpdateTime不存在则将其排在最后
if (!a.lastUpdateTime) return 1;
if (!b.lastUpdateTime) return -1;
// 首先按时间降序排列(最新的在前面)
const timeCompare =
new Date(b.lastUpdateTime).getTime() -
new Date(a.lastUpdateTime).getTime();
// 如果时间相同,则按未读消息数量降序排列
if (timeCompare === 0) {
// 如果unreadCount不存在则将其排在后面
const aUnread = a.unreadCount || 0;
const bUnread = b.unreadCount || 0;
return bUnread - aUnread; // 未读消息多的排在前面
}
return timeCompare;
},
);
//会话数据同步
asyncChatSessions(sortedSessions);
return {
contractList,
groupList,
kfUserList,
};
} catch (error) {
console.error("获取联系人列表失败:", error);
return [];
}
};
//发起soket连接
export const initSocket = () => {
// 检查WebSocket是否已经连接
const { status } = useWebSocketStore.getState();
// 如果已经连接或正在连接,则不重复连接
if (["connected", "connecting"].includes(status)) {
console.log("WebSocket已连接或正在连接跳过重复连接", { status });
return;
}
// 从store获取token和accountId
const { token2 } = useUserStore.getState();
const { getAccountId } = useCkChatStore.getState();
const Token = token2;
const accountId = getAccountId();
// 使用WebSocket store初始化连接
const { connect } = useWebSocketStore.getState();
// 连接WebSocket
connect({
accessToken: Token,
accountId: Number(accountId),
client: "kefu-client",
cmdType: "CmdSignIn",
seq: +new Date(),
});
};
export const getCountLables = async () => {
const LablesRes = await Promise.all(
[1, 2].map(item =>
WechatGroup({
groupType: item,
}),
),
);
const [friend, group] = LablesRes;
const countLables = [
...[
{
id: 0,
groupName: "默认群分组",
groupType: 2,
},
],
...group,
...friend,
...[
{
id: 0,
groupName: "未分组",
groupType: 1,
},
],
];
return countLables;
};
/**
* 根据标签组织联系人
* @param contractList 联系人列表
* @param countLables 标签列表
* @returns 按标签分组的联系人
*/
//获取控制终端列表
export const getControlTerminalListByWechatAccountIds = (
WechatAccountIds: number[],
) => {
return Promise.all(
WechatAccountIds.map(id => getControlTerminalList({ id: id })),
);
};
// 递归获取所有联系人列表
export const getAllContactList = async () => {
try {
let allContacts = [];
let prevId = 0;
const count = 1000;
let hasMore = true;
while (hasMore) {
const contractList = await getContactList({
prevId,
count,
});
if (
!contractList ||
!Array.isArray(contractList) ||
contractList.length === 0
) {
hasMore = false;
break;
}
allContacts = [...allContacts, ...contractList];
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
if (contractList.length < count) {
hasMore = false;
} else {
// 获取最后一条数据的id作为下一次请求的prevId
const lastContact = contractList[contractList.length - 1];
prevId = lastContact.id;
}
}
return allContacts;
} catch (error) {
console.error("获取所有联系人列表失败:", error);
return [];
}
};
// 提取不重复的wechatAccountId组
export const getUniqueWechatAccountIds = (
contacts: ContractData[],
groupList: weChatGroup[],
) => {
if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
return [];
}
// 使用Set来存储不重复的wechatAccountId
const uniqueAccountIdsSet = new Set<number>();
// 遍历联系人列表将每个wechatAccountId添加到Set中
contacts.forEach(contact => {
if (contact && contact.wechatAccountId) {
uniqueAccountIdsSet.add(contact.wechatAccountId);
}
});
// 遍历联系人列表将每个wechatAccountId添加到Set中
groupList.forEach(group => {
if (group && group.wechatAccountId) {
uniqueAccountIdsSet.add(group.wechatAccountId);
}
});
// 将Set转换为数组并返回
return Array.from(uniqueAccountIdsSet);
};
// 递归获取所有群列表
export const getAllGroupList = async () => {
try {
let allContacts = [];
let prevId = 0;
const count = 1000;
let hasMore = true;
while (hasMore) {
const contractList = await getGroupList({
prevId,
count,
});
if (
!contractList ||
!Array.isArray(contractList) ||
contractList.length === 0
) {
hasMore = false;
break;
}
allContacts = [...allContacts, ...contractList];
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
if (contractList.length < count) {
hasMore = false;
} else {
// 获取最后一条数据的id作为下一次请求的prevId
const lastContact = contractList[contractList.length - 1];
prevId = lastContact.id;
}
}
return allContacts;
} catch (error) {
console.error("获取所有群列表失败:", error);
return [];
}
};
//获取token
const getToken = () => {
return new Promise((resolve, reject) => {
const params = {
grant_type: "password",
password: "kr123456",
username: "kr_xf3",
// username: "karuo",
// password: "zhiqun1984",
};
loginWithToken(params)
.then(res => {
login2(res.access_token);
resolve(res.access_token);
})
.catch(err => {
reject(err);
});
});
};

View File

@@ -1,287 +0,0 @@
import request from "@/api/request2";
import {
MessageData,
ChatHistoryResponse,
MessageType,
OnlineStatus,
MessageStatus,
FileUploadResponse,
EmojiData,
QuickReply,
ChatSettings,
} from "./data";
//读取聊天信息
//kf.quwanzhi.com:9991/api/WechatFriend/clearUnreadCount
function jsonToQueryString(json) {
const params = new URLSearchParams();
for (const key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
params.append(key, json[key]);
}
}
return params.toString();
}
//转移客户
export function WechatFriendAllot(params: {
wechatFriendId?: number;
wechatChatroomId?: number;
toAccountId: number;
notifyReceiver: boolean;
comment: string;
}) {
return request(
"/api/wechatFriend/allot?" + jsonToQueryString(params),
undefined,
"PUT",
);
}
//获取可转移客服列表
export function getTransferableAgentList() {
return request("/api/account/myDepartmentAccountsForTransfer", {}, "GET");
}
// 微信好友列表
export function WechatFriendRebackAllot(params: {
wechatFriendId?: number;
wechatChatroomId?: number;
}) {
return request(
"/api/wechatFriend/rebackAllot?" + jsonToQueryString(params),
undefined,
"PUT",
);
}
// 微信群列表
export function WechatGroup(params) {
return request("/api/WechatGroup/list", params, "GET");
}
//获取聊天记录-1 清除未读
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 getChatMessages(params: {
wechatAccountId: number;
wechatFriendId?: number;
wechatChatroomId?: number;
From: number;
To: number;
Count: number;
olderData: boolean;
}) {
return request("/api/FriendMessage/SearchMessage", params, "GET");
}
export function getChatroomMessages(params: {
wechatAccountId: number;
wechatFriendId?: number;
wechatChatroomId?: number;
From: number;
To: number;
Count: number;
olderData: boolean;
}) {
return request("/api/ChatroomMessage/SearchMessage", params, "GET");
}
//获取群列表
export function getGroupList(params: { prevId: number; count: number }) {
return request(
"/api/wechatChatroom/listExcludeMembersByPage?",
params,
"GET",
);
}
//获取群成员
export function getGroupMembers(params: { id: number }) {
return request(
"/api/WechatChatroom/listMembersByWechatChatroomId",
params,
"GET",
);
}
//触客宝登陆
export function loginWithToken(params: any) {
return request(
"/token",
params,
"POST",
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
1000,
);
}
// 获取触客宝用户信息
export function getChuKeBaoUserInfo() {
return request("/api/account/self", {}, "GET");
}
// 获取联系人列表
export const getContactList = (params: { prevId: number; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};
//获取控制终端列表
export const getControlTerminalList = params => {
return request("/api/wechataccount", params, "GET");
};
// 获取聊天历史
export const getChatHistory = (
chatId: string,
page: number = 1,
pageSize: number = 50,
): Promise<ChatHistoryResponse> => {
return request(`/v1/chats/${chatId}/messages`, { page, pageSize }, "GET");
};
// 发送消息
export const sendMessage = (
chatId: string,
content: string,
type: MessageType = MessageType.TEXT,
): Promise<MessageData> => {
return request(`/v1/chats/${chatId}/messages`, { content, type }, "POST");
};
// 发送文件消息
export const sendFileMessage = (
chatId: string,
file: File,
type: MessageType,
): Promise<MessageData> => {
const formData = new FormData();
formData.append("file", file);
formData.append("type", type);
return request(`/v1/chats/${chatId}/messages/file`, formData, "POST");
};
// 标记消息为已读
export const markMessageAsRead = (messageId: string): Promise<void> => {
return request(`/v1/messages/${messageId}/read`, {}, "PUT");
};
// 标记聊天为已读
export const markChatAsRead = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/read`, {}, "PUT");
};
// 添加群组成员
export const addGroupMembers = (
groupId: string,
memberIds: string[],
): Promise<void> => {
return request(`/v1/groups/${groupId}/members`, { memberIds }, "POST");
};
// 移除群组成员
export const removeGroupMembers = (
groupId: string,
memberIds: string[],
): Promise<void> => {
return request(`/v1/groups/${groupId}/members`, { memberIds }, "DELETE");
};
// 获取在线状态
export const getOnlineStatus = (userId: string): Promise<OnlineStatus> => {
return request(`/v1/users/${userId}/status`, {}, "GET");
};
// 获取消息状态
export const getMessageStatus = (messageId: string): Promise<MessageStatus> => {
return request(`/v1/messages/${messageId}/status`, {}, "GET");
};
// 上传文件
export const uploadFile = (file: File): Promise<FileUploadResponse> => {
const formData = new FormData();
formData.append("file", file);
return request("/v1/upload", formData, "POST");
};
// 获取表情包列表
export const getEmojiList = (): Promise<EmojiData[]> => {
return request("/v1/emojis", {}, "GET");
};
// 获取快捷回复列表
export const getQuickReplies = (): Promise<QuickReply[]> => {
return request("/v1/quick-replies", {}, "GET");
};
// 添加快捷回复
export const addQuickReply = (data: {
content: string;
category: string;
}): Promise<QuickReply> => {
return request("/v1/quick-replies", data, "POST");
};
// 删除快捷回复
export const deleteQuickReply = (id: string): Promise<void> => {
return request(`/v1/quick-replies/${id}`, {}, "DELETE");
};
// 获取聊天设置
export const getChatSettings = (): Promise<ChatSettings> => {
return request("/v1/chat/settings", {}, "GET");
};
// 更新聊天设置
export const updateChatSettings = (
settings: Partial<ChatSettings>,
): Promise<ChatSettings> => {
return request("/v1/chat/settings", settings, "PUT");
};
// 删除聊天会话
export const deleteChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}`, {}, "DELETE");
};
// 置顶聊天会话
export const pinChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/pin`, {}, "PUT");
};
// 取消置顶聊天会话
export const unpinChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/unpin`, {}, "PUT");
};
// 静音聊天会话
export const muteChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/mute`, {}, "PUT");
};
// 取消静音聊天会话
export const unmuteChatSession = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/unmute`, {}, "PUT");
};
// 转发消息
export const forwardMessage = (
messageId: string,
targetChatIds: string[],
): Promise<void> => {
return request("/v1/messages/forward", { messageId, targetChatIds }, "POST");
};
// 撤回消息
export const recallMessage = (messageId: string): Promise<void> => {
return request(`/v1/messages/${messageId}/recall`, {}, "PUT");
};

View File

@@ -1,404 +0,0 @@
.chatWindow {
height: 100%;
display: flex;
flex-direction: row;
}
.chatMain {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chatHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
height: 64px;
min-height: 64px;
flex-shrink: 0;
gap: 16px; // 确保信息区域和按钮区域有足够间距
.chatHeaderInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0; // 防止flex子元素溢出
:global(.ant-avatar) {
flex-shrink: 0;
width: 40px;
height: 40px;
}
.chatHeaderDetails {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
.chatHeaderName {
font-size: 16px;
font-weight: 600;
color: #262626;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 30px;
.chatHeaderOnlineStatus {
font-size: 12px;
color: #52c41a;
font-weight: normal;
flex-shrink: 0; // 防止在线状态被压缩
}
}
.chatHeaderType {
font-size: 12px;
color: #8c8c8c;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chatHeaderSubInfo {
display: flex;
gap: 12px;
margin-top: 2px;
font-size: 12px;
overflow: hidden;
.chatHeaderRemark {
color: #1890ff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chatHeaderWechatId {
color: #8c8c8c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.headerButton {
color: #8c8c8c;
border: none;
padding: 8px;
&:hover {
color: #1890ff;
background: #f5f5f5;
}
}
}
.chatContent {
flex: 1;
overflow: visible;
background: #f5f5f5;
display: flex;
flex-direction: column;
.messagesContainer {
height: 100%;
overflow-y: auto;
padding: 16px;
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
color: #8c8c8c;
}
}
}
// 右侧个人资料卡片
.profileSider {
background: #fff;
border-left: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
.profileSiderContent {
display: flex;
flex-direction: column;
height: 100%;
.profileHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.closeButton {
color: #8c8c8c;
border: none;
padding: 4px;
&:hover {
color: #1890ff;
background: #f5f5f5;
}
}
}
.profileContent {
flex: 1;
overflow-y: auto;
padding: 16px;
height: 0; // 确保flex子元素能够正确计算高度
.profileCard {
margin-bottom: 16px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:last-child {
margin-bottom: 0;
}
:global(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
padding: 0 16px;
:global(.ant-card-head-title) {
font-size: 14px;
font-weight: 600;
color: #262626;
}
}
:global(.ant-card-body) {
padding: 16px;
}
}
.profileBasic {
display: flex;
align-items: center;
gap: 16px;
:global(.ant-avatar) {
flex-shrink: 0;
width: 48px;
height: 48px;
}
.profileInfo {
flex: 1;
min-width: 0;
overflow: hidden;
h4 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #262626;
}
.profileNickname {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
cursor: pointer;
}
.profileStatus {
margin: 0 0 4px 0;
font-size: 12px;
color: #52c41a;
}
.profilePosition {
margin: 0;
font-size: 12px;
color: #8c8c8c;
}
.profileRemark {
margin: 0 0 4px 0;
font-size: 12px;
color: #1890ff;
:global(.ant-input) {
font-size: 12px;
}
:global(.ant-btn) {
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
}
.profileWechatId {
margin: 0;
font-size: 12px;
color: #8c8c8c;
}
}
}
.contractInfo {
.contractItem {
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: #262626;
&:last-child {
margin-bottom: 0;
}
:global(.anticon) {
color: #8c8c8c;
font-size: 16px;
width: 16px;
}
.contractItemText {
padding-left: 10px;
}
}
}
.tagsContainer {
display: flex;
flex-wrap: wrap;
gap: 6px;
:global(.ant-tag) {
margin: 0;
border-radius: 12px;
font-size: 12px;
}
}
.bioText {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #595959;
}
.profileActions {
margin-top: 24px;
}
}
}
}
.messageTime {
text-align: center;
padding: 4px 0;
font-size: 12px;
color: #999;
margin: 20px 0;
}
// 响应式设计
@media (max-width: 1200px) {
.profileSider {
width: 260px !important;
}
}
@media (max-width: 768px) {
.chatWindow {
flex-direction: column;
}
.profileSider {
width: 100% !important;
height: 300px;
border-left: none;
border-top: 1px solid #f0f0f0;
}
.chatHeader {
padding: 0 12px;
height: 56px;
min-height: 56px;
.chatHeaderInfo {
.chatHeaderDetails {
.chatHeaderName {
font-size: 14px;
}
}
}
}
.chatContent {
.messagesContainer {
padding: 12px;
}
}
.profileContent {
padding: 12px;
.profileCard {
margin-bottom: 12px;
:global(.ant-card-body) {
padding: 12px;
}
}
.profileBasic {
gap: 12px;
.profileInfo {
h4 {
font-size: 16px;
}
}
}
.contractInfo {
.contractItem {
font-size: 13px;
margin-bottom: 10px;
}
}
}
}

View File

@@ -1,181 +0,0 @@
// 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

@@ -1,150 +0,0 @@
import React, { useState } from "react";
import { Button, Modal, Input, DatePicker, message } from "antd";
import { MessageOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { RangePicker } = DatePicker;
interface ChatRecordProps {
className?: string;
disabled?: boolean;
}
const ChatRecord: React.FC<ChatRecordProps> = ({
className,
disabled = false,
}) => {
const [visible, setVisible] = useState(false);
const [searchContent, setSearchContent] = useState<string>("");
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(
null,
);
const [loading, setLoading] = useState(false);
const SearchMessage = useWeChatStore(state => state.SearchMessage);
// 打开弹窗
const openModal = () => {
setVisible(true);
};
// 关闭弹窗并重置状态
const closeModal = () => {
setVisible(false);
setSearchContent("");
setDateRange(null);
setLoading(false);
};
// 执行查找
const handleSearch = async () => {
if (!dateRange) {
message.warning("请选择时间范围");
return;
}
try {
setLoading(true);
const [From, To] = dateRange;
const searchData = {
From: From.unix() * 1000,
To: To.unix() * 1000,
keyword: searchContent.trim(),
};
await SearchMessage(searchData);
message.success("查找完成");
closeModal();
} catch (error) {
console.error("查找失败:", error);
message.error("查找失败,请重试");
} finally {
setLoading(false);
}
};
return (
<>
<div
className={className}
onClick={openModal}
style={{
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
}}
>
<MessageOutlined />
</div>
<Modal
title="查找聊天记录"
open={visible}
onCancel={closeModal}
width={450}
centered
maskClosable={!loading}
footer={[
<div
key="footer"
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
>
<Button
onClick={closeModal}
disabled={loading}
style={{ marginRight: "8px" }}
>
</Button>
<Button type="primary" loading={loading} onClick={handleSearch}>
</Button>
</div>,
]}
>
<div style={{ padding: "20px 0" }}>
{/* 时间范围选择 */}
<div style={{ marginBottom: "20px" }}>
<div
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
>
</div>
<RangePicker
value={dateRange}
onChange={setDateRange}
style={{ width: "100%" }}
size="large"
disabled={loading}
placeholder={["开始日期", "结束日期"]}
/>
</div>
{/* 查找内容输入 */}
<div>
<div
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
>
</div>
<Input
placeholder="请输入要查找的关键词或内容"
value={searchContent}
onChange={e => setSearchContent(e.target.value)}
size="large"
maxLength={100}
showCount
disabled={loading}
/>
</div>
</div>
</Modal>
</>
);
};
export default ChatRecord;

View File

@@ -1,253 +0,0 @@
import React, { useState } from "react";
import { Button, Modal, Select, Input, message } from "antd";
import { ShareAltOutlined } from "@ant-design/icons";
import {
getTransferableAgentList,
WechatFriendAllot,
WechatFriendRebackAllot,
} from "@/pages/pc/ckbox/weChat/api";
import { useCurrentContact } from "@/store/module/weChat/weChat";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { contractService, weChatGroupService } from "@/utils/db";
const { TextArea } = Input;
const { Option } = Select;
interface ToContractProps {
className?: string;
disabled?: boolean;
}
interface DepartItem {
id: number;
userName: string;
realName: string;
nickname: string;
avatar: string;
memo: string;
departmentId: number;
alive: boolean;
}
const ToContract: React.FC<ToContractProps> = ({
className,
disabled = false,
}) => {
const currentContact = useCurrentContact();
const [visible, setVisible] = useState(false);
const [selectedTarget, setSelectedTarget] = useState<number | null>(null);
const [comment, setComment] = useState<string>("");
const [loading, setLoading] = useState(false);
const [customerServiceList, setCustomerServiceList] = useState<DepartItem[]>(
[],
);
const deleteChatSession = useCkChatStore(state => state.deleteChatSession);
// 打开弹窗
const openModal = () => {
setVisible(true);
getTransferableAgentList().then(data => {
setCustomerServiceList(data);
});
};
// 关闭弹窗并重置状态
const closeModal = () => {
setVisible(false);
setSelectedTarget(null);
setComment("");
setLoading(false);
};
// 确定转给他人
const handleConfirm = async () => {
if (!selectedTarget) {
message.warning("请选择目标客服");
return;
}
try {
setLoading(true);
console.log(currentContact);
// 调用转接接口
if (currentContact) {
if ("chatroomId" in currentContact && currentContact.chatroomId) {
await WechatFriendAllot({
wechatChatroomId: currentContact.id,
toAccountId: selectedTarget as number,
notifyReceiver: true,
comment: comment.trim(),
});
} else {
await WechatFriendAllot({
wechatFriendId: currentContact.id,
toAccountId: selectedTarget as number,
notifyReceiver: true,
comment: comment.trim(),
});
}
}
message.success("转接成功");
try {
// 删除聊天会话
deleteChatSession(currentContact.id);
// 删除本地数据库记录
if ("chatroomId" in currentContact) {
await weChatGroupService.delete(currentContact.id);
} else {
await contractService.delete(currentContact.id);
}
} catch (deleteError) {
console.error("删除本地数据失败:", deleteError);
}
closeModal();
} catch (error) {
console.error("转接失败:", error);
message.error("转接失败,请重试");
} finally {
setLoading(false);
}
};
// 一键转回
const handleReturn = async () => {
try {
setLoading(true);
// 调用转回接口
if (currentContact) {
if ("chatroomId" in currentContact && currentContact.chatroomId) {
await WechatFriendRebackAllot({
wechatChatroomId: currentContact.id,
});
} else {
await WechatFriendRebackAllot({
wechatFriendId: currentContact.id,
});
}
}
message.success("转回成功");
try {
// 删除聊天会话
deleteChatSession(currentContact.id);
// 删除本地数据库记录
if ("chatroomId" in currentContact) {
await weChatGroupService.delete(currentContact.id);
} else {
await contractService.delete(currentContact.id);
}
} catch (deleteError) {
console.error("删除本地数据失败:", deleteError);
}
closeModal();
} catch (error) {
console.error("转回失败:", error);
message.error("转回失败,请重试");
} finally {
setLoading(false);
}
};
return (
<>
<div
className={className}
onClick={openModal}
style={{
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
}}
>
<ShareAltOutlined />
</div>
<Modal
title="转给他人"
open={visible}
onCancel={closeModal}
width={400}
centered
maskClosable={!loading}
footer={[
<div
key="footer"
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
}}
>
<Button onClick={handleReturn} disabled={loading}>
</Button>
<div>
<Button
onClick={closeModal}
disabled={loading}
style={{ marginRight: "8px" }}
>
</Button>
<Button
type="primary"
loading={loading}
onClick={handleConfirm}
disabled={!selectedTarget}
>
</Button>
</div>
</div>,
]}
>
<div style={{ padding: "20px 0" }}>
{/* 目标客服选择 */}
<div style={{ marginBottom: "20px" }}>
<div
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
>
</div>
<Select
placeholder="请选择目标客服"
value={selectedTarget}
onChange={setSelectedTarget}
style={{ width: "100%" }}
size="large"
disabled={loading}
>
{customerServiceList.map(item => (
<Option key={item.id} value={item.id}>
{item.nickname || item.realName} - {item.userName}
</Option>
))}
</Select>
</div>
{/* 附言输入 */}
<div>
<div
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
>
</div>
<TextArea
placeholder="请输入附言内容"
value={comment}
onChange={e => setComment(e.target.value)}
rows={4}
maxLength={300}
showCount
style={{ resize: "none" }}
disabled={loading}
/>
</div>
</div>
</Modal>
</>
);
};
export default ToContract;

View File

@@ -1,291 +0,0 @@
import React, { useState } from "react";
import { Layout, Input, Button, Modal } from "antd";
import {
SendOutlined,
FolderOutlined,
PictureOutlined,
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { EmojiPicker } from "@/components/EmojiSeclection";
import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
import AudioRecorder from "@/components/Upload/AudioRecorder";
import ToContract from "./components/toContract";
import ChatRecord from "./components/chatRecord";
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 handleEmojiSelect = (emoji: EmojiInfo) => {
setInputValue(prevValue => prevValue + `[${emoji.name}]`);
};
// 根据文件格式判断消息类型
const getMsgTypeByFileFormat = (filePath: string): number => {
const extension = filePath.toLowerCase().split(".").pop() || "";
// 图片格式
const imageFormats = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
];
if (imageFormats.includes(extension)) {
return 3; // 图片
}
// 视频格式
const videoFormats = [
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"3gp",
"rmvb",
];
if (videoFormats.includes(extension)) {
return 43; // 视频
}
// 其他格式默认为文件
return 49; // 文件
};
const FileType = {
TEXT: 1,
IMAGE: 2,
VIDEO: 3,
AUDIO: 4,
FILE: 5,
};
const handleFileUploaded = (
filePath: string | { url: string; durationMs: number },
fileType: number,
) => {
// msgType(1:文本 3:图片 43:视频 47:动图表情包gif、其他表情包 49:小程序/其他:图文、文件)
let msgType = 1;
if ([FileType.TEXT].includes(fileType)) {
msgType = getMsgTypeByFileFormat(filePath as string);
} else if ([FileType.IMAGE].includes(fileType)) {
msgType = 3;
} else if ([FileType.AUDIO].includes(fileType)) {
msgType = 34;
}
const params = {
wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType,
content: [FileType.AUDIO].includes(fileType)
? JSON.stringify(filePath)
: filePath,
};
sendCommand("CmdSendMessage", params);
};
return (
<>
{/* 聊天输入 */}
<Footer className={styles.chatFooter}>
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.FILE)
}
maxSize={1}
type={4}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<FolderOutlined />}
/>
}
/>
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.IMAGE)
}
maxSize={1}
type={1}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<PictureOutlined />}
/>
}
/>
<AudioRecorder
onAudioUploaded={audioData =>
handleFileUploaded(audioData, FileType.AUDIO)
}
className={styles.toolbarButton}
/>
</div>
<div className={styles.rightTool}>
<ToContract className={styles.rightToolItem} />
<ChatRecord className={styles.rightToolItem} />
</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,584 +0,0 @@
// 消息容器
.messagesContainer {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
background: #f5f5f5;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
&:hover {
background: #bfbfbf;
}
}
}
// 时间分组
.messageTime {
text-align: center;
color: #8c8c8c;
font-size: 12px;
margin: 8px 0;
position: relative;
font-weight: normal;
a {
color: #1890ff;
margin: 0px 5px;
}
}
.loadMore {
text-align: center;
color: #1890ff;
cursor: pointer;
}
// 消息项
.messageItem {
display: flex;
margin-bottom: 12px;
&.ownMessage {
justify-content: flex-end;
.messageContent {
flex-direction: row-reverse;
}
.messageBubble {
background: #1890ff;
color: white;
border-radius: 10px;
}
}
&.otherMessage {
justify-content: flex-start;
.messageBubble {
background: white;
color: #262626;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
}
// 消息内容容器
.messageContent {
display: flex;
align-items: flex-start;
gap: 8px;
}
// 头像
.messageAvatar {
flex-shrink: 0;
margin-top: 2px;
}
// 消息气泡
.messageBubble {
padding: 8px 12px;
max-width: 100%;
word-wrap: break-word;
position: relative;
}
// 发送者名称
.messageSender {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
font-weight: 500;
}
// 普通文本消息
.messageText {
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
// 表情包消息
.emojiMessage {
img {
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
}
// 视频消息
.videoMessage {
.videoContainer {
position: relative;
display: inline-block;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.02);
.playButton {
background: rgba(0, 0, 0, 0.8);
}
}
}
.videoPreview {
max-width: 200px;
max-height: 200px;
display: block;
}
.videoThumbnail {
max-width: 200px;
max-height: 200px;
display: block;
cursor: pointer;
transition: opacity 0.2s;
}
.videoPlayIcon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.loadingSpinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.downloadButton {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(0, 0, 0, 0.8);
color: white;
}
}
.playButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
svg {
margin-left: 2px; // 视觉居中调整
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// 图片消息
.imageMessage {
img {
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
transform: scale(1.02);
}
}
}
// 小程序消息基础样式
.miniProgramMessage {
background: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
// 通用小程序卡片基础样式
.miniProgramCard {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid #e1e8ed;
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 280px;
min-height: 64px;
overflow: hidden;
}
// 通用小程序元素样式
.miniProgramThumb {
width: 50px;
height: 50px;
object-fit: cover;
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
.miniProgramInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miniProgramTitle {
padding-left: 16px;
font-weight: 600;
color: #1a1a1a;
font-size: 14px;
line-height: 1.4;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.01em;
}
.miniProgramApp {
font-size: 12px;
color: #8c8c8c;
line-height: 1.2;
font-weight: 500;
padding: 6px 12px;
}
}
// 类型1小程序样式默认横向布局
.miniProgramType1 {
// 继承基础样式,无需额外定义
}
// 类型2小程序样式垂直图片布局
.miniProgramType2 {
.miniProgramCardType2 {
flex-direction: column;
align-items: stretch;
padding: 0;
min-height: 220px;
max-width: 280px;
.miniProgramAppTop {
padding: 12px 16px 8px;
font-size: 13px;
font-weight: 500;
color: #495057;
background: #f8f9fa;
display: flex;
align-items: center;
&::before {
content: "📱";
font-size: 12px;
}
}
.miniProgramImageArea {
width: calc(100% - 32px);
height: 0;
padding-bottom: 75%; // 4:3 宽高比
margin: 0px 16px;
overflow: hidden;
position: relative;
background: #f8f9fa;
.miniProgramImage {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
border: 0.5px solid #e1e8ed;
&:hover {
transform: scale(1.05);
}
}
}
.miniProgramContent {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
.miniProgramTitle {
font-size: 14px;
font-weight: 600;
color: #212529;
line-height: 1.4;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.miniProgramIdentifier {
font-size: 11px;
color: #6c757d;
border-radius: 8px;
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 3px;
&::before {
content: "🏷️";
font-size: 9px;
}
}
}
}
}
// 链接类型消息样式
.linkMessage {
.linkCard {
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.linkDescription {
font-size: 12px;
color: #666;
line-height: 1.4;
margin: 4px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 文章类型消息样式
.articleMessage {
.articleCard {
flex-direction: column;
align-items: stretch;
padding: 16px;
min-height: auto;
max-width: 320px;
}
.articleTitle {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.articleContent {
display: flex;
gap: 12px;
align-items: flex-start;
}
.articleTextArea {
flex: 1;
min-width: 0;
}
.articleDescription {
font-size: 13px;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.articleImageArea {
flex-shrink: 0;
width: 60px;
height: 60px;
overflow: hidden;
border-radius: 8px;
background: #f8f9fa;
}
.articleImage {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
}
// 文件消息样式
.fileMessage {
.fileCard {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition: all 0.2s;
max-width: 250px;
&:hover {
background: #f0f0f0;
border-color: #1890ff;
}
}
.fileIcon {
font-size: 24px;
color: #1890ff;
flex-shrink: 0;
}
.fileInfo {
flex: 1;
min-width: 0;
}
.fileName {
font-weight: 500;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.fileAction {
font-size: 12px;
color: #1890ff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.messageBubble {
padding: 6px 10px;
}
.videoMessage .videoPreview {
max-width: 150px;
max-height: 150px;
}
// 小程序消息移动端适配
.miniProgramMessage {
.miniProgramCard {
max-width: 260px;
padding: 10px 14px;
min-height: 56px;
border-radius: 10px;
}
.miniProgramThumb {
width: 36px;
height: 36px;
border-radius: 6px;
&:hover {
transform: none;
}
}
.miniProgramTitle {
font-size: 13px;
line-height: 1.3;
font-weight: 500;
}
.miniProgramApp {
font-size: 11px;
padding: 1px 4px;
&::before {
width: 12px;
height: 12px;
}
}
}
}

View File

@@ -1,139 +0,0 @@
// 消息气泡样式
.messageBubble {
word-wrap: break-word;
border-radius: 8px;
background-color: #f0f0f0;
}
// 音频错误提示样式
.audioError {
color: #ff4d4f;
font-size: 12px;
margin-bottom: 8px;
padding: 4px 8px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
text-align: center;
}
// 语音消息容器
.audioMessage {
min-width: 200px;
max-width: 100%;
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
// 音频控制容器
.audioContainer {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: background-color 0.2s;
width: 100%;
box-sizing: border-box;
overflow: hidden;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
// 播放图标
.audioIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
&:hover {
transform: scale(1.05);
}
}
// 音频内容区域
.audioContent {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
// 波形动画容器
.audioWaveform {
display: flex;
align-items: center;
gap: 2px;
height: 30px; // 固定高度防止抖动
}
// 波形条
.waveBar {
width: 3px;
background-color: #d9d9d9;
border-radius: 1.5px;
transition: all 0.3s ease;
transform-origin: center; // 设置变换原点为中心
&.playing {
animation: waveAnimation 1.5s ease-in-out infinite;
}
}
// 音频时长显示
.audioDuration {
font-size: 12px;
color: #666;
white-space: nowrap;
}
// 音频文本显示
.audioText {
font-size: 11px;
color: #666;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
}
// 进度条容器
.audioProgress {
margin-top: 8px;
height: 2px;
background-color: #e0e0e0;
border-radius: 1px;
overflow: hidden;
}
// 进度条
.audioProgressBar {
height: 100%;
background-color: #1890ff;
border-radius: 1px;
transition: width 0.1s ease;
}
// 波形动画
@keyframes waveAnimation {
0%,
100% {
transform: scaleY(0.5);
}
50% {
transform: scaleY(1.2);
}
}

View File

@@ -1,230 +0,0 @@
import React, { useState, useRef } from "react";
import { PauseCircleFilled, SoundOutlined } from "@ant-design/icons";
import styles from "./AudioMessage.module.scss";
interface AudioMessageProps {
audioUrl: string;
msgId: string;
}
interface AudioData {
durationMs?: number;
url: string;
text?: string;
}
// 解析音频URL支持两种格式纯URL字符串和JSON字符串
const parseAudioUrl = (audioUrl: string): AudioData => {
try {
// 尝试解析为JSON
const parsed = JSON.parse(audioUrl);
if (parsed.url) {
return {
durationMs: parsed.durationMs,
url: parsed.url,
text: parsed.text,
};
}
} catch (error) {
// 如果解析失败说明是纯URL字符串
}
// 返回纯URL格式
return {
url: audioUrl,
};
};
// 测试音频URL是否可访问避免CORS问题
const testAudioUrl = async (url: string): Promise<boolean> => {
try {
// 对于阿里云OSS等外部资源直接返回true让Audio对象自己处理
// 避免fetch HEAD请求触发CORS问题
if (url.includes(".aliyuncs.com") || url.includes("oss-")) {
return true;
}
const response = await fetch(url, { method: "HEAD" });
return response.ok;
} catch (error) {
// 如果fetch失败可能是CORS问题返回true让Audio对象尝试加载
return true;
}
};
const AudioMessage: React.FC<AudioMessageProps> = ({ audioUrl, msgId }) => {
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
const [audioProgress, setAudioProgress] = useState<Record<string, number>>(
{},
);
const [audioError, setAudioError] = useState<string | null>(null);
const audioRefs = useRef<Record<string, HTMLAudioElement>>({});
// 解析音频数据
const audioData = parseAudioUrl(audioUrl);
const actualAudioUrl = audioData.url;
const audioDuration = audioData.durationMs;
const audioText = audioData.text;
const audioId = `audio_${msgId}_${Date.now()}`;
const isPlaying = playingAudioId === audioId;
const progress = audioProgress[audioId] || 0;
// 格式化时长显示
const formatDuration = (ms?: number): string => {
if (!ms) return "语音";
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
// 播放/暂停音频
const handleAudioToggle = async () => {
const audio = audioRefs.current[audioId];
if (!audio) {
// 先测试URL是否可访问
const isUrlAccessible = await testAudioUrl(actualAudioUrl);
if (!isUrlAccessible) {
setAudioError("音频文件无法访问,请检查网络连接");
return;
}
// 清除之前的错误状态
setAudioError(null);
const newAudio = new Audio();
// 对于阿里云OSS等外部资源不设置crossOrigin避免CORS问题
// 只有在需要访问音频数据时才设置crossOrigin
if (
!actualAudioUrl.includes(".aliyuncs.com") &&
!actualAudioUrl.includes("oss-")
) {
newAudio.crossOrigin = "anonymous";
}
newAudio.preload = "metadata";
audioRefs.current[audioId] = newAudio;
newAudio.addEventListener("timeupdate", () => {
const currentProgress =
(newAudio.currentTime / newAudio.duration) * 100;
setAudioProgress(prev => ({
...prev,
[audioId]: currentProgress,
}));
});
newAudio.addEventListener("ended", () => {
setPlayingAudioId(null);
setAudioProgress(prev => ({ ...prev, [audioId]: 0 }));
});
newAudio.addEventListener("error", e => {
setPlayingAudioId(null);
setAudioError("音频播放失败,请稍后重试");
});
// 设置音频源并尝试播放
newAudio.src = actualAudioUrl;
try {
await newAudio.play();
setPlayingAudioId(audioId);
} catch (error) {
setPlayingAudioId(null);
setAudioError("音频播放失败,请检查音频格式或网络连接");
console.error("音频播放错误:", error);
}
} else {
if (isPlaying) {
audio.pause();
setPlayingAudioId(null);
} else {
// 停止其他正在播放的音频
Object.values(audioRefs.current).forEach(a => a.pause());
setPlayingAudioId(null);
try {
await audio.play();
setPlayingAudioId(audioId);
} catch (error) {
setPlayingAudioId(null);
setAudioError("音频播放失败,请稍后重试");
}
}
}
};
return (
<>
<div className={styles.messageBubble}>
{audioError && (
<div
className={styles.audioError}
onClick={() => {
setAudioError(null);
handleAudioToggle();
}}
style={{ cursor: "pointer" }}
title="点击重试"
>
{audioError} ()
</div>
)}
<div className={styles.audioMessage}>
<div className={styles.audioContainer} onClick={handleAudioToggle}>
<div className={styles.audioIcon}>
{isPlaying ? (
<PauseCircleFilled
style={{ fontSize: "20px", color: "#1890ff" }}
/>
) : (
<SoundOutlined style={{ fontSize: "20px", color: "#666" }} />
)}
</div>
<div className={styles.audioContent}>
<div className={styles.audioWaveform}>
{/* 音频波形效果 */}
{Array.from({ length: 20 }, (_, i) => (
<div
key={i}
className={`${styles.waveBar} ${isPlaying ? styles.playing : ""}`}
style={{
height: `${Math.random() * 20 + 10}px`,
animationDelay: `${i * 0.1}s`,
backgroundColor: progress > i * 5 ? "#1890ff" : "#d9d9d9",
}}
/>
))}
</div>
<div className={styles.audioDuration}>
{formatDuration(audioDuration)}
</div>
</div>
</div>
{progress > 0 && (
<div className={styles.audioProgress}>
<div
className={styles.audioProgressBar}
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
</div>
<div>
{audioText && (
<div className={styles.audioText} title={audioText}>
{audioText.length > 10
? `${audioText.substring(0, 10)}...`
: audioText}
</div>
)}
</div>
</>
);
};
export default AudioMessage;

View File

@@ -1,315 +0,0 @@
// 通用消息文本样式
.messageText {
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
// 小程序消息基础样式
.miniProgramMessage {
background: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
// 通用小程序卡片基础样式
.miniProgramCard {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid #e1e8ed;
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 280px;
min-height: 64px;
overflow: hidden;
}
// 通用小程序元素样式
.miniProgramThumb {
width: 50px;
height: 50px;
object-fit: cover;
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
.miniProgramInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miniProgramTitle {
padding-left: 16px;
font-weight: 600;
color: #1a1a1a;
font-size: 14px;
line-height: 1.4;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.01em;
}
.miniProgramApp {
font-size: 12px;
color: #8c8c8c;
line-height: 1.2;
font-weight: 500;
padding: 6px 12px;
}
}
// 类型1小程序样式默认横向布局
.miniProgramType1 {
// 继承基础样式,无需额外定义
}
// 类型2小程序样式垂直图片布局
.miniProgramType2 {
.miniProgramCardType2 {
flex-direction: column;
align-items: stretch;
padding: 0;
min-height: 220px;
max-width: 280px;
.miniProgramAppTop {
padding: 12px 16px 8px;
font-size: 13px;
font-weight: 500;
color: #495057;
background: #f8f9fa;
display: flex;
align-items: center;
&::before {
content: "📱";
font-size: 12px;
}
}
.miniProgramImageArea {
width: calc(100% - 32px);
height: 0;
padding-bottom: 75%; // 4:3 宽高比
margin: 0px 16px;
overflow: hidden;
position: relative;
background: #f8f9fa;
.miniProgramImage {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
border: 0.5px solid #e1e8ed;
&:hover {
transform: scale(1.05);
}
}
}
.miniProgramContent {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
.miniProgramTitle {
font-size: 14px;
font-weight: 600;
color: #212529;
line-height: 1.4;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.miniProgramIdentifier {
font-size: 11px;
color: #6c757d;
border-radius: 8px;
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 3px;
&::before {
content: "🏷️";
font-size: 9px;
}
}
}
}
}
// 文章类型消息样式
.articleMessage {
.articleCard {
flex-direction: column;
align-items: stretch;
padding: 16px;
min-height: auto;
max-width: 320px;
}
.articleTitle {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.articleContent {
display: flex;
gap: 12px;
align-items: flex-start;
}
.articleTextArea {
flex: 1;
min-width: 0;
}
.articleDescription {
font-size: 13px;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.articleImageArea {
flex-shrink: 0;
width: 60px;
height: 60px;
overflow: hidden;
border-radius: 8px;
background: #f8f9fa;
}
.articleImage {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
}
// 文件消息样式
.fileMessage {
.fileCard {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition: all 0.2s;
max-width: 250px;
&:hover {
background: #f0f0f0;
border-color: #1890ff;
}
}
.fileIcon {
font-size: 24px;
color: #1890ff;
flex-shrink: 0;
}
.fileInfo {
flex: 1;
min-width: 0;
}
.fileName {
font-weight: 500;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.fileAction {
font-size: 12px;
color: #1890ff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
// 响应式设计
@media (max-width: 768px) {
// 小程序消息移动端适配
.miniProgramMessage {
.miniProgramCard {
max-width: 260px;
padding: 10px 14px;
min-height: 56px;
border-radius: 10px;
}
.miniProgramThumb {
width: 36px;
height: 36px;
border-radius: 6px;
&:hover {
transform: none;
}
}
.miniProgramTitle {
font-size: 13px;
line-height: 1.3;
font-weight: 500;
}
.miniProgramApp {
font-size: 11px;
padding: 1px 4px;
&::before {
width: 12px;
height: 12px;
}
}
}
}

View File

@@ -1,259 +0,0 @@
import React from "react";
import { parseWeappMsgStr } from "@/utils/common";
import styles from "./SmallProgramMessage.module.scss";
interface SmallProgramMessageProps {
content: string;
}
const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
content,
}) => {
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[小程序/文章/文件消息 - 无效内容]");
}
try {
const trimmedContent = content.trim();
// 尝试解析JSON格式的消息
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
const messageData = JSON.parse(trimmedContent);
// 处理文章类型消息
if (messageData.type === "link" && messageData.title && messageData.url) {
const { title, desc, thumbPath, url } = messageData;
return (
<div
className={`${styles.miniProgramMessage} ${styles.articleMessage}`}
>
<div
className={`${styles.miniProgramCard} ${styles.articleCard}`}
onClick={() => window.open(url, "_blank")}
>
{/* 标题在第一行 */}
<div className={styles.articleTitle}>{title}</div>
{/* 下方:文字在左,图片在右 */}
<div className={styles.articleContent}>
<div className={styles.articleTextArea}>
{desc && (
<div className={styles.articleDescription}>{desc}</div>
)}
</div>
{thumbPath && (
<div className={styles.articleImageArea}>
<img
src={thumbPath}
alt="文章缩略图"
className={styles.articleImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
)}
</div>
</div>
<div className={styles.miniProgramApp}></div>
</div>
);
}
// 处理小程序消息 - 统一使用parseWeappMsgStr解析
if (messageData.type === "miniprogram" && messageData.contentXml) {
try {
const parsedData = parseWeappMsgStr(trimmedContent);
if (parsedData.appmsg) {
const { appmsg } = parsedData;
const title = appmsg.title || "小程序消息";
const appName =
appmsg.sourcedisplayname || appmsg.appname || "小程序";
// 获取小程序类型
const miniProgramType =
appmsg.weappinfo && appmsg.weappinfo.type
? parseInt(appmsg.weappinfo.type)
: 1;
// 根据type类型渲染不同布局
if (miniProgramType === 2) {
// 类型2图片区域布局
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
>
<div
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
>
<div className={styles.miniProgramAppTop}>{appName}</div>
<div className={styles.miniProgramTitle}>{title}</div>
<div className={styles.miniProgramImageArea}>
<img
src={parsedData.previewImage}
alt="小程序图片"
className={styles.miniProgramImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}></div>
</div>
</div>
</div>
);
} else {
// 默认类型:横向布局
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div className={styles.miniProgramCard}>
<img
src={parsedData.previewImage}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
</div>
</div>
<div className={styles.miniProgramApp}>{appName}</div>
</div>
);
}
}
} catch (parseError) {
console.error("parseWeappMsgStr解析失败:", parseError);
return renderErrorMessage("[小程序消息 - 解析失败]");
}
}
// 验证传统JSON格式的小程序数据结构
if (
messageData &&
typeof messageData === "object" &&
(messageData.title || messageData.appName)
) {
return (
<div className={styles.miniProgramMessage}>
<div className={styles.miniProgramCard}>
{messageData.thumb && (
<img
src={messageData.thumb}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>
{messageData.title || "小程序消息"}
</div>
{messageData.appName && (
<div className={styles.miniProgramApp}>
{messageData.appName}
</div>
)}
</div>
</div>
</div>
);
}
}
// 增强的文件消息处理
const isFileUrl =
content.startsWith("http") ||
content.startsWith("https") ||
content.startsWith("file://") ||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(content);
if (isFileUrl) {
// 尝试从URL中提取文件名
const fileName = content.split("/").pop()?.split("?")[0] || "文件";
const fileExtension = fileName.split(".").pop()?.toLowerCase();
// 根据文件类型选择图标
let fileIcon = "📄";
if (fileExtension) {
const iconMap: { [key: string]: string } = {
pdf: "📕",
doc: "📘",
docx: "📘",
xls: "📗",
xlsx: "📗",
ppt: "📙",
pptx: "📙",
txt: "📝",
zip: "🗜️",
rar: "🗜️",
"7z": "🗜️",
jpg: "🖼️",
jpeg: "🖼️",
png: "🖼️",
gif: "🖼️",
mp4: "🎬",
avi: "🎬",
mov: "🎬",
mp3: "🎵",
wav: "🎵",
flac: "🎵",
};
fileIcon = iconMap[fileExtension] || "📄";
}
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>{fileIcon}</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{fileName.length > 20
? fileName.substring(0, 20) + "..."
: fileName}
</div>
<div
className={styles.fileAction}
onClick={() => {
try {
window.open(content, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
}}
>
</div>
</div>
</div>
</div>
);
}
return renderErrorMessage("[小程序/文件消息]");
} catch (e) {
console.warn("小程序/文件消息解析失败:", e);
return renderErrorMessage("[小程序/文件消息 - 解析失败]");
}
};
export default SmallProgramMessage;

View File

@@ -1,153 +0,0 @@
// 通用消息文本样式
.messageText {
color: #666;
font-style: italic;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
// 消息气泡样式
.messageBubble {
display: inline-block;
max-width: 70%;
padding: 8px 12px;
border-radius: 12px;
word-wrap: break-word;
position: relative;
}
// 视频消息样式
.videoMessage {
position: relative;
display: inline-block;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: #000;
.videoContainer {
position: relative;
display: inline-block;
cursor: pointer;
video {
display: block;
max-width: 300px;
max-height: 400px;
border-radius: 8px;
}
}
.videoThumbnail {
display: block;
max-width: 300px;
max-height: 400px;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.3s ease;
}
.videoPlayIcon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 2;
.loadingSpinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
.downloadButton {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
color: white;
border: none;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
&:hover {
background: rgba(0, 0, 0, 0.8);
color: white;
}
}
.playButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
svg {
margin-left: 2px; // 视觉居中调整
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// 响应式设计
@media (max-width: 768px) {
.messageBubble {
padding: 6px 10px;
}
.videoMessage .videoThumbnail,
.videoMessage .videoContainer video {
max-width: 200px;
max-height: 250px;
}
.videoMessage .videoPlayIcon {
.loadingSpinner {
width: 36px;
height: 36px;
border-width: 3px;
}
}
.videoMessage .downloadButton {
width: 28px;
height: 28px;
top: 6px;
right: 6px;
svg {
font-size: 14px !important;
}
}
}

View File

@@ -1,182 +0,0 @@
import React from "react";
import { DownloadOutlined, PlayCircleFilled } from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import styles from "./VideoMessage.module.scss";
interface VideoMessageProps {
content: string;
msg: ChatRecord;
contract: ContractData | weChatGroup;
}
const VideoMessage: React.FC<VideoMessageProps> = ({
content,
msg,
contract,
}) => {
// 检测是否为直接视频链接的函数
const isDirectVideoLink = (content: string): boolean => {
const trimmedContent = content.trim();
return (
trimmedContent.startsWith("http") &&
(trimmedContent.includes(".mp4") ||
trimmedContent.includes(".mov") ||
trimmedContent.includes(".avi") ||
trimmedContent.includes("video"))
);
};
// 处理视频播放请求发送socket请求获取真实视频地址
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
console.log("发送视频下载请求:", { messageId, tencentUrl });
// 先设置加载状态
useWeChatStore.getState().setVideoLoading(messageId, true);
// 构建socket请求数据
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
chatroomMessageId: contract.chatroomId ? messageId : 0,
friendMessageId: contract.chatroomId ? 0 : messageId,
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
tencentUrl: tencentUrl,
wechatAccountId: contract.wechatAccountId,
});
};
// 渲染错误消息
const renderErrorMessage = (message: string) => (
<div className={styles.messageText}>{message}</div>
);
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[视频消息 - 无效内容]");
}
// 如果content是直接的视频链接已预览过或下载好的视频
if (isDirectVideoLink(content)) {
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={content}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={content}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
</div>
</div>
);
}
try {
// 尝试解析JSON格式的视频数据
if (content.startsWith("{") && content.endsWith("}")) {
const videoData = JSON.parse(content);
// 验证必要的视频数据字段
if (
videoData &&
typeof videoData === "object" &&
videoData.previewImage &&
videoData.tencentUrl
) {
const previewImageUrl = String(videoData.previewImage).replace(
/[`"']/g,
"",
);
// 创建点击处理函数
const handlePlayClick = (e: React.MouseEvent, msg: ChatRecord) => {
e.stopPropagation();
// 如果没有视频URL且不在加载中则发起下载请求
if (!videoData.videoUrl && !videoData.isLoading) {
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
}
};
// 如果已有视频URL显示视频播放器
if (videoData.videoUrl) {
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={videoData.videoUrl}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={videoData.videoUrl}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
</div>
</div>
);
}
// 显示预览图,根据加载状态显示不同的图标
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div
className={styles.videoContainer}
onClick={e => handlePlayClick(e, msg)}
>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.videoPlayIcon}>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
)}
</div>
</div>
</div>
</div>
);
}
}
return renderErrorMessage("[视频消息]");
} catch (e) {
console.warn("视频消息解析失败:", e);
return renderErrorMessage("[视频消息 - 解析失败]");
}
};
export default VideoMessage;

View File

@@ -1,588 +0,0 @@
import React, { useEffect, useRef } from "react";
import { Avatar, Divider } from "antd";
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
import AudioMessage from "./components/AudioMessage/AudioMessage";
import SmallProgramMessage from "./components/SmallProgramMessage";
import VideoMessage from "./components/VideoMessage";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
import styles from "./MessageRecord.module.scss";
import { useWeChatStore } from "@/store/module/weChat/weChat";
interface MessageRecordProps {
contract: ContractData | weChatGroup;
}
const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const currentMessages = useWeChatStore(state => state.currentMessages);
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
const messagesLoading = useWeChatStore(state => state.messagesLoading);
const isLoadingData = useWeChatStore(state => state.isLoadingData);
const currentGroupMembers = useWeChatStore(
state => state.currentGroupMembers,
);
const prevMessagesRef = useRef(currentMessages);
// 判断是否为表情包URL的工具函数
const isEmojiUrl = (content: string): boolean => {
return (
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
/\.(gif|webp|png|jpg|jpeg)$/i.test(content) ||
content.includes("emoji") ||
content.includes("sticker") ||
content.includes("expression")
);
};
// 解析表情包文字格式[表情名称]并替换为img标签
const parseEmojiText = (text: string): React.ReactNode[] => {
const emojiRegex = /\[([^\]]+)\]/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = emojiRegex.exec(text)) !== null) {
// 添加表情前的文字
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// 获取表情名称并查找对应路径
const emojiName = match[1];
const emojiPath = getEmojiPath(emojiName as any);
if (emojiPath) {
// 如果找到表情添加img标签
parts.push(
<img
key={`emoji-${match.index}`}
src={emojiPath}
alt={emojiName}
className={styles.emojiImage}
style={{
width: "20px",
height: "20px",
margin: "0 2px",
display: "inline",
lineHeight: "20px",
float: "left",
}}
/>,
);
} else {
// 如果没找到表情,保持原文字
parts.push(match[0]);
}
lastIndex = emojiRegex.lastIndex;
}
// 添加剩余的文字
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
};
useEffect(() => {
const prevMessages = prevMessagesRef.current;
const hasVideoStateChange = currentMessages.some((msg, index) => {
// 首先检查消息对象本身是否为null或undefined
if (!msg || !msg.content) return false;
const prevMsg = prevMessages[index];
if (!prevMsg || !prevMsg.content || prevMsg.id !== msg.id) return false;
try {
const currentContent =
typeof msg.content === "string"
? JSON.parse(msg.content)
: msg.content;
const prevContent =
typeof prevMsg.content === "string"
? JSON.parse(prevMsg.content)
: prevMsg.content;
// 检查视频状态是否发生变化开始加载、完成加载、获得URL
const currentHasVideo =
currentContent.previewImage && currentContent.tencentUrl;
const prevHasVideo = prevContent.previewImage && prevContent.tencentUrl;
if (currentHasVideo && prevHasVideo) {
// 检查加载状态变化或视频URL变化
return (
currentContent.isLoading !== prevContent.isLoading ||
currentContent.videoUrl !== prevContent.videoUrl
);
}
return false;
} catch (e) {
return false;
}
});
// 只有在没有视频状态变化时才自动滚动到底部
if (!hasVideoStateChange && isLoadingData) {
scrollToBottom();
}
// 更新上一次的消息状态
prevMessagesRef.current = currentMessages;
}, [currentMessages, isLoadingData]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// 解析消息内容根据msgType判断消息类型并返回对应的渲染内容
const parseMessageContent = (
content: string | null | undefined,
msg: ChatRecord,
msgType?: number,
) => {
// 处理null或undefined的内容
if (content === null || content === undefined) {
return <div className={styles.messageText}></div>;
}
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
// 根据msgType进行消息类型判断
switch (msgType) {
case 1: // 文本消息
return (
<div className={styles.messageBubble}>
<div className={styles.messageText}>{parseEmojiText(content)}</div>
</div>
);
case 3: // 图片消息
// 验证是否为有效的图片URL
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[图片消息 - 无效链接]");
}
return (
<div className={styles.messageBubble}>
<div className={styles.imageMessage}>
<img
src={content}
alt="图片消息"
style={{
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
}}
onClick={() => window.open(content, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
}
}}
/>
</div>
</div>
);
case 43: // 视频消息
return (
<VideoMessage content={content || ""} msg={msg} contract={contract} />
);
case 47: // 动图表情包gif、其他表情包
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[表情包 - 无效链接]");
}
// 使用工具函数判断表情包URL
if (isEmojiUrl(content)) {
return (
<div className={styles.emojiMessage}>
<img
src={content}
alt="表情包"
style={{ maxWidth: "120px", maxHeight: "120px" }}
onClick={() => window.open(content, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
}
}}
/>
</div>
);
}
return renderErrorMessage("[表情包]");
case 34: // 语音消息
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[语音消息 - 无效内容]");
}
// content直接是音频URL字符串
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
case 49: // 小程序/文章/其他:图文、文件
return <SmallProgramMessage content={content || ""} />;
default: {
// 兼容旧版本和未知消息类型的处理逻辑
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage(
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
);
}
// 智能识别消息类型(兼容旧版本数据)
const contentStr = content.trim();
// 1. 检查是否为表情包(兼容旧逻辑)
const isLegacyEmoji =
contentStr.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
/\.(gif|webp|png|jpg|jpeg)$/i.test(contentStr) ||
contentStr.includes("emoji") ||
contentStr.includes("sticker");
if (isLegacyEmoji) {
return (
<div className={styles.emojiMessage}>
<img
src={contentStr}
alt="表情包"
style={{ maxWidth: "120px", maxHeight: "120px" }}
onClick={() => window.open(contentStr, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
}
}}
/>
</div>
);
}
// 2. 检查是否为JSON格式消息包括视频、链接等
if (contentStr.startsWith("{") && contentStr.endsWith("}")) {
try {
const jsonData = JSON.parse(contentStr);
// 检查是否为链接类型消息
if (jsonData.type === "link" && jsonData.title && jsonData.url) {
const { title, desc, thumbPath, url } = jsonData;
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div
className={`${styles.miniProgramCard} ${styles.linkCard}`}
onClick={() => window.open(url, "_blank")}
>
{thumbPath && (
<img
src={thumbPath}
alt="链接缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
{desc && (
<div className={styles.linkDescription}>{desc}</div>
)}
</div>
</div>
<div className={styles.miniProgramApp}></div>
</div>
);
}
// 检查是否为视频消息(兼容旧逻辑)
if (
jsonData &&
typeof jsonData === "object" &&
jsonData.previewImage &&
(jsonData.tencentUrl || jsonData.videoUrl)
) {
const previewImageUrl = String(jsonData.previewImage).replace(
/[`"']/g,
"",
);
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoPreview}
onClick={() => {
const videoUrl =
jsonData.videoUrl || jsonData.tencentUrl;
if (videoUrl) {
window.open(videoUrl, "_blank");
}
}}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.playButton}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="white"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
);
}
} catch (e) {
console.warn("兼容模式JSON解析失败:", e);
}
}
// 3. 检查是否为图片链接
const isImageUrl =
contentStr.startsWith("http") &&
/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(contentStr);
if (isImageUrl) {
return (
<div className={styles.imageMessage}>
<img
src={contentStr}
alt="图片消息"
style={{
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
}}
onClick={() => window.open(contentStr, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
}
}}
/>
</div>
);
}
// 4. 检查是否为文件链接
const isFileLink =
contentStr.startsWith("http") &&
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(
contentStr,
);
if (isFileLink) {
const fileName = contentStr.split("/").pop()?.split("?")[0] || "文件";
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>📄</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{fileName.length > 20
? fileName.substring(0, 20) + "..."
: fileName}
</div>
<div
className={styles.fileAction}
onClick={() => window.open(contentStr, "_blank")}
>
</div>
</div>
</div>
</div>
);
}
// 5. 默认按文本消息处理
return (
<div className={styles.messageText}>{parseEmojiText(content)}</div>
);
}
}
};
// 获取群成员头像
const groupMemberAvatar = (msg: ChatRecord) => {
const groupMembers = currentGroupMembers.find(
v => v?.wechatId == msg?.sender?.wechatId,
);
return groupMembers?.avatar;
};
// 清理微信ID前缀
const clearWechatidInContent = (sender: any, content: string) => {
try {
return content.replace(new RegExp(`${sender?.wechatId}:\n`, "g"), "");
} catch (err) {
return "-";
}
};
// 用于分组消息并添加时间戳的辅助函数
const groupMessagesByTime = (messages: ChatRecord[]) => {
return messages
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
.map(msg => ({
time: formatWechatTime(msg?.wechatTime),
messages: [msg],
}));
};
// 渲染单条消息
const renderMessage = (msg: ChatRecord) => {
// 添加null检查防止访问null对象的属性
if (!msg) return null;
const isOwn = msg?.isSend;
const isGroup = !!contract.chatroomId;
return (
<div
key={msg.id || `msg-${Date.now()}`}
className={`${styles.messageItem} ${
isOwn ? styles.ownMessage : styles.otherMessage
}`}
>
<div className={styles.messageContent}>
{/* 如果不是群聊 */}
{!isGroup && !isOwn && (
<>
<Avatar
size={32}
src={contract.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{contract.nickname}
</div>
)}
<>{parseMessageContent(msg?.content, msg, msg?.msgType)}</>
</div>
</>
)}
{/* 如果是群聊 */}
{isGroup && !isOwn && (
<>
<Avatar
size={32}
src={groupMemberAvatar(msg)}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{msg?.sender?.nickname}
</div>
)}
<>
{parseMessageContent(
clearWechatidInContent(msg?.sender, msg?.content),
msg,
msg?.msgType,
)}
</>
</div>
</>
)}
{isOwn && <>{parseMessageContent(msg?.content, msg, msg?.msgType)}</>}
</div>
</div>
);
};
const loadMoreMessages = () => {
// 兼容性处理:检查消息数组和时间戳
if (!currentMessages || currentMessages.length === 0) {
console.warn("No messages available for loading more");
return;
}
const firstMessage = currentMessages[0];
if (!firstMessage || !firstMessage.createTime) {
console.warn("Invalid message or createTime");
return;
}
// 兼容性处理:确保时间戳格式正确
let timestamp;
try {
const date = new Date(firstMessage.createTime);
if (isNaN(date.getTime())) {
console.warn("Invalid createTime format:", firstMessage.createTime);
return;
}
timestamp = date.getTime() - 20000;
} catch (error) {
console.error("Error parsing createTime:", error);
return;
}
loadChatMessages(false, timestamp);
};
return (
<div className={styles.messagesContainer}>
<div className={styles.loadMore} onClick={() => loadMoreMessages()}>
{messagesLoading ? <LoadingOutlined /> : ""}
</div>
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
{group.messages
.filter(v => [10000].includes(v.msgType))
.map(msg => (
<div
key={`divider-${msg.id}`}
className={styles.messageTime}
dangerouslySetInnerHTML={{ __html: msg.content }}
></div>
))}
<div className={styles.messageTime}>{group.time}</div>
{group.messages
.filter(v => ![10000].includes(v.msgType))
.map(renderMessage)}
</React.Fragment>
))}
<div ref={messagesEndRef} />
</div>
);
};
export default MessageRecord;

View File

@@ -1,243 +0,0 @@
.profileSider {
background: #fff;
border-left: 1px solid #e8e8e8;
height: 100%;
overflow-y: auto;
.profileContainer {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
}
.profileHeader {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
.closeButton {
color: #8c8c8c;
&:hover {
color: #262626;
background: #f5f5f5;
}
}
}
.profileBasic {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
.profileInfo {
margin-top: 16px;
text-align: center;
width: 100%;
.profileNickname {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #262626;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profileRemark {
margin-bottom: 12px;
.remarkText {
color: #8c8c8c;
font-size: 14px;
cursor: pointer;
&:hover {
color: #1890ff;
}
}
}
.profileStatus {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #52c41a;
font-size: 14px;
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #52c41a;
}
}
}
}
.profileCard {
margin-bottom: 16px;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
:global(.ant-card-head) {
padding: 0 16px;
min-height: 40px;
border-bottom: 1px solid #f0f0f0;
:global(.ant-card-head-title) {
font-size: 14px;
font-weight: 500;
color: #262626;
}
}
:global(.ant-card-body) {
padding: 16px;
}
.infoItem {
display: flex;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.infoIcon {
color: #8c8c8c;
margin-right: 8px;
width: 16px;
flex-shrink: 0;
}
.infoLabel {
color: #8c8c8c;
font-size: 14px;
width: 60px;
flex-shrink: 0;
}
.infoValue {
color: #262626;
font-size: 14px;
flex: 1;
word-break: break-all;
// 备注编辑区域样式
:global(.ant-input) {
font-size: 14px;
}
:global(.ant-btn) {
font-size: 12px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.tagsContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
:global(.ant-tag) {
margin: 0;
border-radius: 4px;
}
}
.bioText {
margin: 0;
color: #595959;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
}
.groupManagement {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.groupMemberList {
.groupMember {
display: flex;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
span {
margin-left: 8px;
font-size: 14px;
color: #262626;
}
}
}
.profileActions {
margin-top: auto;
padding-top: 16px;
:global(.ant-btn) {
border-radius: 6px;
font-weight: 500;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.profileSider {
width: 280px !important;
.profileContainer {
padding: 12px;
}
.profileBasic {
.profileInfo {
.profileNickname {
font-size: 16px;
}
}
}
.profileCard {
:global(.ant-card-body) {
padding: 12px;
}
.infoItem {
margin-bottom: 10px;
.infoLabel {
width: 50px;
font-size: 13px;
}
.infoValue {
font-size: 13px;
}
}
}
}
}

View File

@@ -1,669 +0,0 @@
import React, { useEffect, useRef } from "react";
import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd";
import {
PhoneOutlined,
VideoCameraOutlined,
MoreOutlined,
UserOutlined,
DownloadOutlined,
FileOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
PlayCircleFilled,
TeamOutlined,
FolderOutlined,
EnvironmentOutlined,
} from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import styles from "./ChatWindow.module.scss";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { formatWechatTime } from "@/utils/common";
import ProfileCard from "./components/ProfileCard";
import MessageEnter from "./components/MessageEnter";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { Header, Content } = Layout;
interface ChatWindowProps {
contract: ContractData | weChatGroup;
showProfile?: boolean;
onToggleProfile?: () => void;
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contract,
showProfile = true,
onToggleProfile,
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const currentMessages = useWeChatStore(state => state.currentMessages);
const prevMessagesRef = useRef(currentMessages);
useEffect(() => {
const prevMessages = prevMessagesRef.current;
const hasVideoStateChange = currentMessages.some((msg, index) => {
// 首先检查消息对象本身是否为null或undefined
if (!msg || !msg.content) return false;
const prevMsg = prevMessages[index];
if (!prevMsg || !prevMsg.content || prevMsg.id !== msg.id) return false;
try {
const currentContent =
typeof msg.content === "string"
? JSON.parse(msg.content)
: msg.content;
const prevContent =
typeof prevMsg.content === "string"
? JSON.parse(prevMsg.content)
: prevMsg.content;
// 检查视频状态是否发生变化开始加载、完成加载、获得URL
const currentHasVideo =
currentContent.previewImage && currentContent.tencentUrl;
const prevHasVideo = prevContent.previewImage && prevContent.tencentUrl;
if (currentHasVideo && prevHasVideo) {
// 检查加载状态变化或视频URL变化
return (
currentContent.isLoading !== prevContent.isLoading ||
currentContent.videoUrl !== prevContent.videoUrl
);
}
return false;
} catch (e) {
return false;
}
});
// 只有在没有视频状态变化时才自动滚动到底部
if (!hasVideoStateChange) {
scrollToBottom();
}
// 更新上一次的消息状态
prevMessagesRef.current = currentMessages;
}, [currentMessages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// 处理视频播放请求发送socket请求获取真实视频地址
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
console.log("发送视频下载请求:", { messageId, tencentUrl });
// 先设置加载状态
useWeChatStore.getState().setVideoLoading(messageId, true);
// 构建socket请求数据
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
chatroomMessageId: contract.chatroomId ? messageId : 0,
friendMessageId: contract.chatroomId ? 0 : messageId,
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
tencentUrl: tencentUrl,
wechatAccountId: contract.wechatAccountId,
});
};
// 解析消息内容,判断消息类型并返回对应的渲染内容
const parseMessageContent = (
content: string | null | undefined,
msg: ChatRecord,
) => {
// 处理null或undefined的内容
if (content === null || content === undefined) {
return <div className={styles.messageText}></div>;
}
// 检查是否为表情包
if (
typeof content === "string" &&
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") &&
content.includes("#")
) {
return (
<div className={styles.emojiMessage}>
<img
src={content}
alt="表情包"
style={{ maxWidth: "120px", maxHeight: "120px" }}
onClick={() => window.open(content, "_blank")}
/>
</div>
);
}
// 检查是否为带预览图的视频消息
try {
if (
typeof content === "string" &&
content.trim().startsWith("{") &&
content.trim().endsWith("}")
) {
const videoData = JSON.parse(content);
// 处理视频消息格式 {"previewImage":"https://...", "tencentUrl":"...", "videoUrl":"...", "isLoading":true}
if (videoData.previewImage && videoData.tencentUrl) {
// 提取预览图URL去掉可能的引号
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
// 创建点击处理函数
const handlePlayClick = (e: React.MouseEvent) => {
e.stopPropagation();
// 如果没有视频URL且不在加载中则发起下载请求
if (!videoData.videoUrl && !videoData.isLoading) {
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
}
};
// 如果已有视频URL显示视频播放器
if (videoData.videoUrl) {
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={videoData.videoUrl}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={videoData.videoUrl}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
</div>
);
}
// 显示预览图,根据加载状态显示不同的图标
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer} onClick={handlePlayClick}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
/>
<div className={styles.videoPlayIcon}>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
)}
</div>
</div>
</div>
);
}
// 保留原有的视频处理逻辑
else if (
videoData.type === "video" &&
videoData.url &&
videoData.thumb
) {
return (
<div className={styles.videoMessage}>
<div
className={styles.videoContainer}
onClick={() => window.open(videoData.url, "_blank")}
>
<img
src={videoData.thumb}
alt="视频预览"
className={styles.videoThumbnail}
/>
<div className={styles.videoPlayIcon}>
<VideoCameraOutlined
style={{ fontSize: "32px", color: "#fff" }}
/>
</div>
</div>
<a
href={videoData.url}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
}
} catch (e) {
// 解析JSON失败不是视频消息
console.log("解析视频消息失败:", e);
}
// 检查是否为图片链接
if (
typeof content === "string" &&
(content.match(/\.(jpg|jpeg|png|gif)$/i) ||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
content.includes(".jpg")))
) {
return (
<div className={styles.imageMessage}>
<img
src={content}
alt="图片消息"
onClick={() => window.open(content, "_blank")}
/>
</div>
);
}
// 检查是否为视频链接
if (
typeof content === "string" &&
(content.match(/\.(mp4|avi|mov|wmv|flv)$/i) ||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
content.includes(".mp4")))
) {
return (
<div className={styles.videoMessage}>
<video
controls
src={content}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={content}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
// 检查是否为音频链接
if (
typeof content === "string" &&
(content.match(/\.(mp3|wav|ogg|m4a)$/i) ||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
content.includes(".mp3")))
) {
return (
<div className={styles.audioMessage}>
<audio controls src={content} style={{ maxWidth: "100%" }} />
<a
href={content}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
// 检查是否为Office文件链接
if (
typeof content === "string" &&
content.match(/\.(doc|docx|xls|xlsx|ppt|pptx|pdf)$/i)
) {
const fileName = content.split("/").pop() || "文件";
const fileExt = fileName.split(".").pop()?.toLowerCase();
// 根据文件类型选择不同的图标
let fileIcon = (
<FileOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
/>
);
if (fileExt === "pdf") {
fileIcon = (
<FilePdfOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
/>
);
} else if (fileExt === "doc" || fileExt === "docx") {
fileIcon = (
<FileWordOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#2f54eb" }}
/>
);
} else if (fileExt === "xls" || fileExt === "xlsx") {
fileIcon = (
<FileExcelOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#52c41a" }}
/>
);
} else if (fileExt === "ppt" || fileExt === "pptx") {
fileIcon = (
<FilePptOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#fa8c16" }}
/>
);
}
return (
<div className={styles.fileMessage}>
{fileIcon}
<div className={styles.fileInfo}>
<div
style={{
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{fileName}
</div>
</div>
<a
href={content}
download={fileExt !== "pdf" ? fileName : undefined}
target={fileExt === "pdf" ? "_blank" : undefined}
className={styles.downloadButton}
onClick={e => e.stopPropagation()}
style={{ display: "flex" }}
rel="noreferrer"
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
// 检查是否为文件消息JSON格式
try {
if (
typeof content === "string" &&
content.trim().startsWith("{") &&
content.trim().endsWith("}")
) {
const fileData = JSON.parse(content);
if (fileData.type === "file" && fileData.title) {
// 检查是否为Office文件
const fileExt = fileData.title.split(".").pop()?.toLowerCase();
let fileIcon = (
<FolderOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
/>
);
if (fileExt === "pdf") {
fileIcon = (
<FilePdfOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#ff4d4f",
}}
/>
);
} else if (fileExt === "doc" || fileExt === "docx") {
fileIcon = (
<FileWordOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#2f54eb",
}}
/>
);
} else if (fileExt === "xls" || fileExt === "xlsx") {
fileIcon = (
<FileExcelOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#52c41a",
}}
/>
);
} else if (fileExt === "ppt" || fileExt === "pptx") {
fileIcon = (
<FilePptOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#fa8c16",
}}
/>
);
}
return (
<div className={styles.fileMessage}>
{fileIcon}
<div className={styles.fileInfo}>
<div
style={{
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{fileData.title}
</div>
{fileData.totalLen && (
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
{Math.round(fileData.totalLen / 1024)} KB
</div>
)}
</div>
<a
href={fileData.url || "#"}
download={fileExt !== "pdf" ? fileData.title : undefined}
target={fileExt === "pdf" ? "_blank" : undefined}
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => {
e.stopPropagation();
if (!fileData.url) {
console.log("文件URL不存在");
}
}}
rel="noreferrer"
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
}
} catch (e) {
// 解析JSON失败不是文件消息
}
// 检查是否为位置信息
if (
typeof content === "string" &&
(content.includes("<location") || content.includes("<msg><location"))
) {
// 提取位置信息
const labelMatch = content.match(/label="([^"]*)"/i);
const poiNameMatch = content.match(/poiname="([^"]*)"/i);
const xMatch = content.match(/x="([^"]*)"/i);
const yMatch = content.match(/y="([^"]*)"/i);
const label = labelMatch
? labelMatch[1]
: poiNameMatch
? poiNameMatch[1]
: "位置信息";
const coordinates = xMatch && yMatch ? `${yMatch[1]}, ${xMatch[1]}` : "";
return (
<div className={styles.locationMessage}>
<EnvironmentOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
/>
<div>
<div style={{ fontWeight: "bold" }}>{label}</div>
{coordinates && (
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
{coordinates}
</div>
)}
</div>
</div>
);
}
// 默认为文本消息
return <div className={styles.messageText}>{content}</div>;
};
// 用于分组消息并添加时间戳的辅助函数
const groupMessagesByTime = (messages: ChatRecord[]) => {
return messages
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
.map(msg => ({
time: formatWechatTime(msg?.wechatTime),
messages: [msg],
}));
};
const renderMessage = (msg: ChatRecord) => {
// 添加null检查防止访问null对象的属性
if (!msg) return null;
const isOwn = msg?.isSend;
return (
<div
key={msg.id || `msg-${Date.now()}`}
className={`${styles.messageItem} ${
isOwn ? styles.ownMessage : styles.otherMessage
}`}
>
<div className={styles.messageContent}>
{!isOwn && (
<Avatar
size={32}
src={contract.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
)}
<div className={styles.messageBubble}>
{!isOwn && (
<div className={styles.messageSender}>{msg?.senderName}</div>
)}
{parseMessageContent(msg?.content, msg)}
</div>
</div>
</div>
);
};
const chatMenu = (
<Menu>
<Menu.Item key="profile" icon={<UserOutlined />}>
</Menu.Item>
<Menu.Item key="call" icon={<PhoneOutlined />}>
</Menu.Item>
<Menu.Item key="video" icon={<VideoCameraOutlined />}>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="pin"></Menu.Item>
<Menu.Item key="mute"></Menu.Item>
<Menu.Divider />
<Menu.Item key="clear" danger>
</Menu.Item>
</Menu>
);
return (
<Layout className={styles.chatWindow}>
{/* 聊天主体区域 */}
<Layout className={styles.chatMain}>
{/* 聊天头部 */}
<Header className={styles.chatHeader}>
<div className={styles.chatHeaderInfo}>
<Avatar
size={40}
src={contract.avatar || contract.chatroomAvatar}
icon={
contract.type === "group" ? <TeamOutlined /> : <UserOutlined />
}
/>
<div className={styles.chatHeaderDetails}>
<div className={styles.chatHeaderName}>
{contract.nickname || contract.name}
</div>
</div>
</div>
<Space>
<Tooltip title="语音通话">
<Button
type="text"
icon={<PhoneOutlined />}
className={styles.headerButton}
/>
</Tooltip>
<Tooltip title="视频通话">
<Button
type="text"
icon={<VideoCameraOutlined />}
className={styles.headerButton}
/>
</Tooltip>
<Dropdown overlay={chatMenu} trigger={["click"]}>
<Button
type="text"
icon={<MoreOutlined />}
className={styles.headerButton}
/>
</Dropdown>
</Space>
</Header>
{/* 聊天内容 */}
<Content className={styles.chatContent}>
<div className={styles.messagesContainer}>
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
<div className={styles.messageTime}>{group.time}</div>
{group.messages.map(renderMessage)}
</React.Fragment>
))}
<div ref={messagesEndRef} />
</div>
</Content>
{/* 消息输入组件 */}
<MessageEnter contract={contract} />
</Layout>
{/* 右侧个人资料卡片 */}
<ProfileCard
contract={contract}
showProfile={showProfile}
onToggleProfile={onToggleProfile}
/>
</Layout>
);
};
export default ChatWindow;

View File

@@ -1,108 +0,0 @@
import React, { useState } from "react";
import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd";
import {
PhoneOutlined,
VideoCameraOutlined,
MoreOutlined,
UserOutlined,
TeamOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import styles from "./ChatWindow.module.scss";
import ProfileCard from "./components/ProfileCard";
import MessageEnter from "./components/MessageEnter";
import MessageRecord from "./components/MessageRecord";
const { Header, Content } = Layout;
interface ChatWindowProps {
contract: ContractData | weChatGroup;
}
const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
const [showProfile, setShowProfile] = useState(true);
const onToggleProfile = () => {
setShowProfile(!showProfile);
};
const chatMenu = (
<Menu>
<Menu.Item key="profile" icon={<UserOutlined />}>
</Menu.Item>
<Menu.Item key="call" icon={<PhoneOutlined />}>
</Menu.Item>
<Menu.Item key="video" icon={<VideoCameraOutlined />}>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="pin"></Menu.Item>
<Menu.Item key="mute"></Menu.Item>
<Menu.Divider />
<Menu.Item key="clear" danger>
</Menu.Item>
</Menu>
);
return (
<Layout className={styles.chatWindow}>
{/* 聊天主体区域 */}
<Layout className={styles.chatMain}>
{/* 聊天头部 */}
<Header className={styles.chatHeader}>
<div className={styles.chatHeaderInfo}>
<Avatar
size={40}
src={contract.avatar || contract.chatroomAvatar}
icon={
contract.type === "group" ? <TeamOutlined /> : <UserOutlined />
}
/>
<div className={styles.chatHeaderDetails}>
<div className={styles.chatHeaderName}>
{contract.nickname || contract.name}
</div>
</div>
</div>
<Space>
<Tooltip title="个人资料">
<Button
onClick={onToggleProfile}
type="text"
icon={<InfoCircleOutlined />}
className={styles.headerButton}
/>
</Tooltip>
<Dropdown overlay={chatMenu} trigger={["click"]}>
<Button
type="text"
icon={<MoreOutlined />}
className={styles.headerButton}
/>
</Dropdown>
</Space>
</Header>
{/* 聊天内容 */}
<Content className={styles.chatContent}>
<MessageRecord contract={contract} />
</Content>
{/* 消息输入组件 */}
<MessageEnter contract={contract} />
</Layout>
{/* 右侧个人资料卡片 */}
<ProfileCard
contract={contract}
showProfile={showProfile}
onToggleProfile={onToggleProfile}
/>
</Layout>
);
};
export default ChatWindow;

View File

@@ -1,258 +0,0 @@
.header {
background: #fff;
padding: 0 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
border-bottom: 1px solid #f0f0f0;
}
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
}
.menuButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 6px;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
}
.anticon {
font-size: 18px;
color: #666;
}
}
.title {
font-size: 18px;
color: #333;
margin: 0;
}
.headerRight {
display: flex;
align-items: center;
}
.userInfo {
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.3s;
&:hover {
background-color: #f5f5f5;
}
.suanli {
font-size: 16px;
color: #666;
.suanliIcon {
font-size: 24px;
}
}
}
.avatar {
border: 2px solid #f0f0f0;
transition: all 0.3s;
&:hover {
border-color: #1890ff;
}
}
.username {
font-size: 14px;
color: #333;
font-weight: 500;
margin-left: 8px;
}
// 抽屉样式
.drawer {
:global(.ant-drawer-header) {
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
:global(.ant-drawer-body) {
padding: 0;
}
}
.drawerContent {
height: 100%;
display: flex;
flex-direction: column;
background: #f8f9fa;
}
.drawerHeader {
padding: 20px;
background: #fff;
border-bottom: none;
}
.logoSection {
display: flex;
align-items: center;
gap: 12px;
}
.logoIcon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.logoText {
display: flex;
flex-direction: column;
}
.appName {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.appDesc {
font-size: 12px;
color: #999;
margin: 2px 0 0 0;
}
.drawerBody {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.primaryButton {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.buttonIcon {
font-size: 20px;
}
span {
font-size: 16px;
font-weight: 600;
color: white;
}
}
.menuSection {
margin-top: 8px;
}
.menuItem {
display: flex;
align-items: center;
padding: 16px 20px;
cursor: pointer;
transition: all 0.3s;
border-radius: 8px;
&:hover {
background-color: #f0f0f0;
}
span {
font-size: 16px;
color: #333;
font-weight: 500;
}
}
.menuIcon {
font-size: 20px;
margin-right: 12px;
width: 20px;
text-align: center;
}
.drawerFooter {
padding: 20px;
background: #fff;
border-top: 1px solid #f0f0f0;
.balanceSection {
display: flex;
justify-content: space-between;
align-items: center;
.balanceIcon {
color: #666;
.suanliIcon {
font-size: 20px;
}
}
.balanceText {
color: #3d9c0d;
}
}
}
.balanceLabel {
font-size: 12px;
color: #999;
margin: 0;
}
.balanceAmount {
font-size: 18px;
font-weight: 600;
color: #52c41a;
margin: 2px 0 0 0;
}
// 响应式设计
@media (max-width: 768px) {
.header {
padding: 0 12px;
}
.title {
font-size: 16px;
}
.username {
display: none;
}
.drawer {
width: 280px !important;
}
}

View File

@@ -1,120 +0,0 @@
import React, { useState } from "react";
import { Layout, Drawer, Avatar, Dropdown, Space, Button } from "antd";
import {
MenuOutlined,
UserOutlined,
LogoutOutlined,
SettingOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { useNavigate } from "react-router-dom";
import styles from "./index.module.scss";
const { Header } = Layout;
interface NavCommonProps {
title?: string;
onMenuClick?: () => void;
drawerContent?: React.ReactNode;
}
const NavCommon: React.FC<NavCommonProps> = ({
title = "触客宝",
onMenuClick,
drawerContent,
}) => {
const [drawerVisible, setDrawerVisible] = useState(false);
const { userInfo } = useCkChatStore();
// 处理菜单图标点击
const handleMenuClick = () => {
setDrawerVisible(true);
onMenuClick?.();
};
// 处理抽屉关闭
const handleDrawerClose = () => {
setDrawerVisible(false);
};
// 默认抽屉内容
const defaultDrawerContent = (
<div className={styles.drawerContent}>
<div className={styles.drawerHeader}>
<div className={styles.logoSection}>
<div className={styles.logoIcon}></div>
<div className={styles.logoText}>
<div className={styles.appName}></div>
<div className={styles.appDesc}>AI智能营销系统</div>
</div>
</div>
</div>
<div className={styles.drawerBody}>
<div className={styles.primaryButton}>
<div className={styles.buttonIcon}>🔒</div>
<span>AI智能客服</span>
</div>
<div className={styles.menuSection}>
<div className={styles.menuItem}>
<div className={styles.menuIcon}>📊</div>
<span></span>
</div>
</div>
</div>
<div className={styles.drawerFooter}>
<div className={styles.balanceSection}>
<div className={styles.balanceIcon}>
<span className={styles.suanliIcon}></span>
</div>
<div className={styles.balanceText}>9307.423</div>
</div>
</div>
</div>
);
return (
<>
<Header className={styles.header}>
<div className={styles.headerLeft}>
<Button
type="text"
icon={<MenuOutlined />}
onClick={handleMenuClick}
className={styles.menuButton}
/>
<span className={styles.title}>{title}</span>
</div>
<div className={styles.headerRight}>
<Space className={styles.userInfo}>
<span className={styles.suanli}>
<span className={styles.suanliIcon}></span>
9307.423
</span>
<Avatar
size={40}
icon={<UserOutlined />}
src={userInfo?.account?.avatar}
className={styles.avatar}
/>
</Space>
</div>
</Header>
<Drawer
title="菜单"
placement="left"
onClose={handleDrawerClose}
open={drawerVisible}
width={300}
className={styles.drawer}
>
{drawerContent || defaultDrawerContent}
</Drawer>
</>
);
};
export default NavCommon;

View File

@@ -1,156 +0,0 @@
.messageList {
height: 100%;
overflow-y: auto;
position: relative;
.messageItem {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.3s;
&:hover {
background-color: #f5f5f5;
}
&.active {
background-color: #e6f7ff;
border-right: 3px solid #1890ff;
}
&:last-child {
border-bottom: none;
}
.messageInfo {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
.messageDetails {
flex: 1;
min-width: 0;
.messageHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.messageName {
font-size: 14px;
font-weight: 500;
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messageTime {
font-size: 11px;
color: #bfbfbf;
flex-shrink: 0;
margin-left: 8px;
}
}
.messageContent {
display: flex;
justify-content: space-between;
align-items: center;
.lastMessage {
font-size: 12px;
color: #8c8c8c;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
position: relative;
padding-right: 5px;
height: 18px; // 添加固定高度
line-height: 18px; // 设置行高与高度一致
&::before {
content: attr(data-count);
position: absolute;
right: -5px;
top: 0;
background-color: #ff4d4f;
color: white;
border-radius: 10px;
padding: 0 6px;
font-size: 10px;
line-height: 16px;
min-width: 16px;
height: 16px;
text-align: center;
display: none;
}
&[data-count]:not([data-count=""]):not([data-count="0"]) {
&::before {
display: inline-block;
}
}
}
.onlineIndicator {
font-size: 10px;
color: #52c41a;
flex-shrink: 0;
margin-left: 8px;
}
}
}
}
}
.lastDayMessage {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #f9f9f9;
padding: 8px 16px;
font-size: 12px;
color: #8c8c8c;
text-align: center;
border-top: 1px solid #f0f0f0;
}
}
// 响应式设计
@media (max-width: 768px) {
.messageList {
.messageItem {
padding: 10px 12px;
.messageInfo {
gap: 10px;
.messageDetails {
.messageHeader {
.messageName {
font-size: 13px;
}
.messageTime {
font-size: 10px;
}
}
.messageContent {
.lastMessage {
font-size: 11px;
}
.onlineIndicator {
font-size: 9px;
}
}
}
}
}
}
}

View File

@@ -1,6 +0,0 @@
import request from "@/api/request";
// 获取联系人列表
export const getContactList = (params: { prevId: string; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};

View File

@@ -1,48 +0,0 @@
// 联系人数据接口
export interface ContractData {
id?: number;
wechatAccountId: number;
wechatId: string;
alias: string;
conRemark: string;
nickname: string;
quanPin: string;
avatar?: string;
gender: number;
region: string;
addFrom: number;
phone: string;
labels: string[];
signature: string;
accountId: number;
extendFields: null;
city?: string;
lastUpdateTime: string;
isPassed: boolean;
tenantId: number;
groupId: number;
thirdParty: null;
additionalPicture: string;
desc: string;
config: null;
lastMessageTime: number;
unreadCount: number;
duplicate: boolean;
[key: string]: any;
}
//聊天会话类型
export type ChatType = "private" | "group";
// 聊天会话接口
export interface ChatSession {
id: number;
type: ChatType;
name: string;
avatar?: string;
lastMessage: string;
lastTime: string;
unreadCount: number;
online: boolean;
members?: string[];
pinned?: boolean;
muted?: boolean;
}

View File

@@ -1,97 +0,0 @@
import React, { useEffect, useState } 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 {}
const MessageList: React.FC<MessageListProps> = () => {
const { setCurrentContact, currentContract } = useWeChatStore();
const getChatSessions = useCkChatStore(state => state.chatSessions);
const kfSelected = useCkChatStore(state => state.kfSelected);
const onContactClick = (session: ContractData | weChatGroup) => {
setCurrentContact(session, true);
};
const [chatSessions, setChatSessions] = useState<
(ContractData | weChatGroup)[]
>([]);
const searchKeyword = useCkChatStore(state => state.searchKeyword);
useEffect(() => {
let filteredSessions = getChatSessions;
// 根据客服筛选
if (kfSelected !== 0) {
filteredSessions = filteredSessions.filter(
v => v.wechatAccountId === kfSelected,
);
}
// 根据搜索关键词进行模糊匹配
if (searchKeyword.trim()) {
const keyword = searchKeyword.toLowerCase();
filteredSessions = filteredSessions.filter(v => {
const nickname = (v.nickname || "").toLowerCase();
const conRemark = (v.conRemark || "").toLowerCase();
return nickname.includes(keyword) || conRemark.includes(keyword);
});
}
setChatSessions(filteredSessions);
}, [getChatSessions, kfSelected, searchKeyword]);
return (
<div className={styles.messageList}>
<List
dataSource={chatSessions as (ContractData | weChatGroup)[]}
renderItem={session => (
<List.Item
key={session.id}
className={`${styles.messageItem} ${
currentContract?.id === session.id ? styles.active : ""
}`}
onClick={() => onContactClick(session)}
>
<div className={styles.messageInfo}>
<Badge count={session.unreadCount} size="small">
<Avatar
size={48}
src={session.avatar || session.chatroomAvatar}
icon={
session?.type === "group" ? (
<TeamOutlined />
) : (
<UserOutlined />
)
}
/>
</Badge>
<div className={styles.messageDetails}>
<div className={styles.messageHeader}>
<div className={styles.messageName}>{session.nickname}</div>
<div className={styles.messageTime}>
{formatWechatTime(session?.lastUpdateTime)}
</div>
</div>
<div className={styles.messageContent}>
<div
className={styles.lastMessage}
data-count={
session.unreadCount > 0 ? session.unreadCount : ""
}
>
{session?.lastMessage}
</div>
</div>
</div>
</div>
</List.Item>
)}
/>
</div>
);
};
export default MessageList;

View File

@@ -1,111 +0,0 @@
.sidebarMenu {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
.headerContainer {
padding: 16px 16px 0px 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.searchBar {
margin-bottom: 16px;
padding: 0;
background: #fff;
}
.tabsContainer {
display: flex;
justify-content: space-around;
border-bottom: 1px solid #f0f0f0;
.tabItem {
padding: 8px 0;
flex: 1;
text-align: center;
cursor: pointer;
color: #999;
transition: all 0.3s;
&:hover {
color: #1890ff;
}
&.active {
color: #1890ff;
border-bottom: 2px solid #1890ff;
}
span {
margin-left: 4px;
}
}
}
}
}
// 骨架屏样式
.skeletonContainer {
height: 100%;
padding: 16px;
display: flex;
flex-direction: column;
.searchBarSkeleton {
margin-bottom: 16px;
}
.tabsContainerSkeleton {
display: flex;
justify-content: space-around;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
gap: 8px;
}
.contactListSkeleton {
flex: 1;
overflow-y: auto;
.contactItemSkeleton {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
.contactInfoSkeleton {
margin-left: 12px;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
}
}
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
padding: 20px;
text-align: center;
}
.contentContainer {
flex: 1;
overflow-y: auto;
}
.footer {
padding: 10px;
text-align: center;
border-top: 1px solid #f0f0f0;
background: #fff;
display: none; /* 默认隐藏底部,如果需要显示可以移除此行 */
}

View File

@@ -1,122 +0,0 @@
.contractListSimple {
display: flex;
flex-direction: column;
height: 100%;
background-color: #fff;
color: #333;
.header {
padding: 10px 15px;
font-weight: bold;
border-bottom: 1px solid #f0f0f0;
}
.groupCollapse {
width: 100%;
background-color: transparent;
border: none;
:global(.ant-collapse-item) {
border-bottom: 1px solid #f0f0f0;
}
:global(.ant-collapse-header) {
padding: 10px 15px !important;
font-weight: bold;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
}
}
.groupHeader {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.contactCount {
font-size: 12px;
color: #999;
font-weight: normal;
}
.groupPanel {
background-color: transparent;
}
.loadMoreContainer {
display: flex;
justify-content: center;
padding: 10px 0;
}
.noMoreText {
text-align: center;
color: #999;
font-size: 12px;
padding: 10px 0;
}
.noResults {
text-align: center;
color: #999;
padding: 20px;
font-size: 14px;
}
.list {
flex: 1;
overflow-y: auto;
:global(.ant-list-item) {
padding: 10px 15px;
border-bottom: none;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
}
}
}
.contractItem {
display: flex;
align-items: center;
padding: 8px 15px;
&.selected {
background-color: #f5f5f5;
}
}
.avatarContainer {
margin-right: 10px;
}
.avatar {
background-color: #1890ff;
}
.contractInfo {
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

@@ -1,237 +0,0 @@
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,
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 {
selectedContactId?: ContractData | weChatGroup;
}
const ContactListSimple: React.FC<WechatFriendsProps> = ({
selectedContactId,
}) => {
const [newContractList, setNewContractList] = useState<any[]>([]);
const [searchResults, setSearchResults] = useState<
(ContractData | weChatGroup)[]
>([]);
const getNewContractListFn = useCkChatStore(
state => state.getNewContractList,
);
const kfSelected = useCkChatStore(state => state.kfSelected);
const countLables = useCkChatStore(state => state.countLables);
const searchKeyword = useCkChatStore(state => state.searchKeyword);
// 使用useEffect来处理异步的getNewContractList调用
useEffect(() => {
const fetchData = async () => {
try {
if (searchKeyword.trim()) {
// 有搜索关键词时,获取搜索结果
const searchResult = await searchContactsAndGroups();
setSearchResults(searchResult || []);
setNewContractList([]);
} else {
// 无搜索关键词时,获取分组列表
const result = await getNewContractListFn();
setNewContractList(result || []);
setSearchResults([]);
}
} catch (error) {
console.error("获取联系人数据失败:", error);
setNewContractList([]);
setSearchResults([]);
}
};
fetchData();
}, [getNewContractListFn, kfSelected, countLables, searchKeyword]);
const [activeKey, setActiveKey] = useState<string[]>([]); // 默认展开第一个分组
// 分页加载相关状态
const [visibleContacts, setVisibleContacts] = useState<{
[key: string]: ContractData[];
}>({});
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 | 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 className={styles.contractInfo}>
<div className={styles.name}>{name}</div>
{isGroup && <div className={styles.groupInfo}></div>}
</div>
</List.Item>
);
};
// 初始化分页数据
useEffect(() => {
if (newContractList && newContractList.length > 0) {
const initialVisibleContacts: { [key: string]: ContractData[] } = {};
const initialLoading: { [key: string]: boolean } = {};
const initialHasMore: { [key: string]: boolean } = {};
const initialPage: { [key: string]: number } = {};
newContractList.forEach((group, index) => {
const groupKey = index.toString();
// 每个分组初始加载20条数据
const pageSize = 20;
initialVisibleContacts[groupKey] = group.contacts.slice(0, pageSize);
initialLoading[groupKey] = false;
initialHasMore[groupKey] = group.contacts.length > pageSize;
initialPage[groupKey] = 1;
});
setVisibleContacts(initialVisibleContacts);
setLoading(initialLoading);
setHasMore(initialHasMore);
setPage(initialPage);
}
}, [newContractList]);
// 加载更多联系人
const loadMoreContacts = useCallback(
(groupKey: string) => {
if (loading[groupKey] || !hasMore[groupKey] || !newContractList) return;
setLoading(prev => ({ ...prev, [groupKey]: true }));
// 模拟异步加载
setTimeout(() => {
const groupIndex = parseInt(groupKey);
const group = newContractList[groupIndex];
if (!group) return;
const pageSize = 20;
const currentPage = page[groupKey] || 1;
const nextPage = currentPage + 1;
const startIndex = currentPage * pageSize;
const endIndex = nextPage * pageSize;
const newContacts = group.contacts.slice(startIndex, endIndex);
setVisibleContacts(prev => ({
...prev,
[groupKey]: [...(prev[groupKey] || []), ...newContacts],
}));
setPage(prev => ({ ...prev, [groupKey]: nextPage }));
setHasMore(prev => ({
...prev,
[groupKey]: endIndex < group.contacts.length,
}));
setLoading(prev => ({ ...prev, [groupKey]: false }));
}, 300);
},
[loading, hasMore, page, newContractList],
);
// 渲染加载更多按钮
const renderLoadMoreButton = (groupKey: string) => {
if (!hasMore[groupKey])
return <div className={styles.noMoreText}></div>;
return (
<div className={styles.loadMoreContainer}>
<Button
size="small"
loading={loading[groupKey]}
onClick={() => loadMoreContacts(groupKey)}
>
{loading[groupKey] ? "加载中..." : "加载更多"}
</Button>
</div>
);
};
// 构建Collapse的items属性
const getCollapseItems = (): CollapseProps["items"] => {
if (!newContractList || newContractList.length === 0) return [];
return newContractList.map((group, index) => {
const groupKey = index.toString();
const isActive = activeKey.includes(groupKey);
return {
key: groupKey,
label: (
<div className={styles.groupHeader}>
<span>{group.groupName}</span>
<span className={styles.contactCount}>{group.contacts.length}</span>
</div>
),
className: styles.groupPanel,
children: isActive ? (
<>
<List
className={styles.list}
dataSource={visibleContacts[groupKey] || []}
renderItem={renderContactItem}
/>
{renderLoadMoreButton(groupKey)}
</>
) : null,
};
});
};
return (
<div className={styles.contractListSimple}>
{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>
);
};
export default ContactListSimple;

View File

@@ -1,152 +0,0 @@
import React, { useState } from "react";
import { Input, Skeleton } from "antd";
import {
SearchOutlined,
UserOutlined,
ChromeOutlined,
MessageOutlined,
} from "@ant-design/icons";
import WechatFriends from "./WechatFriends";
import MessageList from "./MessageList/index";
import styles from "./SidebarMenu.module.scss";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
interface SidebarMenuProps {
loading?: boolean;
}
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);
const [activeTab, setActiveTab] = useState("chats");
const handleSearch = (value: string) => {
setSearchKeyword(value);
};
const handleClearSearch = () => {
clearSearchKeyword();
};
// 过滤逻辑已移至store中这里直接使用store返回的已过滤数据
// 渲染骨架屏
const renderSkeleton = () => (
<div className={styles.skeletonContainer}>
<div className={styles.searchBarSkeleton}>
<Skeleton.Input active size="small" block />
</div>
<div className={styles.tabsContainerSkeleton}>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
</div>
<div className={styles.contactListSkeleton}>
{Array(8)
.fill(null)
.map((_, index) => (
<div
key={`contact-skeleton-${index}`}
className={styles.contactItemSkeleton}
>
<Skeleton.Avatar active size="large" shape="circle" />
<div className={styles.contactInfoSkeleton}>
<Skeleton.Input active size="small" style={{ width: "60%" }} />
<Skeleton.Input active size="small" style={{ width: "80%" }} />
</div>
</div>
))}
</div>
</div>
);
// 渲染Header部分包含搜索框和标签页切换
const renderHeader = () => (
<div className={styles.headerContainer}>
{/* 搜索栏 */}
<div className={styles.searchBar}>
<Input
placeholder="搜索客户..."
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={e => handleSearch(e.target.value)}
onClear={handleClearSearch}
allowClear
/>
</div>
{/* 标签页切换 */}
<div className={styles.tabsContainer}>
<div
className={`${styles.tabItem} ${activeTab === "chats" ? styles.active : ""}`}
onClick={() => setActiveTab("chats")}
>
<MessageOutlined />
<span></span>
</div>
<div
className={`${styles.tabItem} ${activeTab === "contracts" ? styles.active : ""}`}
onClick={() => setActiveTab("contracts")}
>
<UserOutlined />
<span></span>
</div>
<div
className={`${styles.tabItem} ${activeTab === "groups" ? styles.active : ""}`}
onClick={() => setActiveTab("groups")}
>
<ChromeOutlined />
<span></span>
</div>
</div>
</div>
);
// 渲染内容部分
const renderContent = () => {
switch (activeTab) {
case "chats":
return <MessageList />;
case "contracts":
return <WechatFriends />;
case "groups":
return (
<div className={styles.emptyState}>
<ChromeOutlined style={{ fontSize: 48, color: "#ccc" }} />
<p></p>
</div>
);
default:
return null;
}
};
if (loading) {
return renderSkeleton();
}
return (
<div className={styles.sidebarMenu}>
{renderHeader()}
<div className={styles.contentContainer}>{renderContent()}</div>
</div>
);
};
export default SidebarMenu;

View File

@@ -1,185 +0,0 @@
// 头部骨架样式
.headerSkeleton {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 16px;
}
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
}
.headerCenter {
flex: 1;
display: flex;
justify-content: center;
}
.headerRight {
display: flex;
align-items: center;
}
// 左侧边栏骨架样式
.siderContent {
height: 100%;
display: flex;
flex-direction: column;
}
.searchBox {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.tabBar {
display: flex;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.contactList {
flex: 1;
overflow-y: auto;
}
.contactItem {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f5f5f5;
&:hover {
background-color: #f8f8f8;
}
}
.contactInfo {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.contactTime {
font-size: 12px;
color: #999;
}
// 聊天区域骨架样式
.chatContent {
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.chatHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.chatHeaderLeft {
display: flex;
align-items: center;
gap: 12px;
}
.chatHeaderRight {
display: flex;
gap: 8px;
}
.messageArea {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.loadingTip {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.loadingText {
font-size: 14px;
color: #666;
text-align: center;
}
.inputArea {
background: #fff;
border-top: 1px solid #f0f0f0;
}
.inputToolbar {
display: flex;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.inputField {
display: flex;
gap: 12px;
padding: 12px 16px;
align-items: flex-end;
}
// 右侧面板骨架样式
.rightPanel {
background: #fff;
border-left: 1px solid #f0f0f0;
}
.profileSection {
padding: 24px;
}
.profileHeader {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
}
.profileDetails {
padding: 24px 0;
border-bottom: 1px solid #f0f0f0;
}
.detailItem {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.tagSection {
padding-top: 24px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View File

@@ -1,210 +0,0 @@
import React from "react";
import { Skeleton, Layout, Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import styles from "./index.module.scss";
import pageStyles from "../../index.module.scss";
const { Header, Content, Sider } = Layout;
interface PageSkeletonProps {
loading: boolean;
children: React.ReactNode;
}
/**
* 页面骨架屏组件
* 在数据加载完成前显示骨架屏
*/
const PageSkeleton: React.FC<PageSkeletonProps> = ({ loading, children }) => {
if (!loading) return <>{children}</>;
const antIcon = <LoadingOutlined style={{ fontSize: 16 }} spin />;
return (
<Layout className={pageStyles.ckboxLayout}>
{/* 顶部标题栏骨架 */}
<Header className={pageStyles.header}>
<div className={styles.headerSkeleton}>
<div className={styles.headerLeft}>
<Skeleton.Avatar active size="small" shape="circle" />
<Skeleton.Input active size="small" style={{ width: "80px" }} />
</div>
<div className={styles.headerCenter}>
<Skeleton.Input active size="small" style={{ width: "200px" }} />
</div>
<div className={styles.headerRight}>
<Skeleton.Button active size="small" style={{ width: "60px" }} />
</div>
</div>
</Header>
<Layout>
{/* 左侧联系人列表骨架 */}
<Sider width={280} className={pageStyles.sider}>
<div className={styles.siderContent}>
{/* 搜索框 */}
<div className={styles.searchBox}>
<Skeleton.Input active size="large" block />
</div>
{/* 标签栏 */}
<div className={styles.tabBar}>
<Skeleton.Button active size="small" style={{ width: "60px" }} />
<Skeleton.Button active size="small" style={{ width: "60px" }} />
<Skeleton.Button active size="small" style={{ width: "60px" }} />
</div>
{/* 联系人列表 */}
<div className={styles.contactList}>
{Array(10)
.fill(null)
.map((_, index) => (
<div key={`contact-${index}`} className={styles.contactItem}>
<Skeleton.Avatar active size="large" shape="circle" />
<div className={styles.contactInfo}>
<Skeleton.Input
active
size="small"
style={{ width: "80px" }}
/>
<Skeleton.Input
active
size="small"
style={{ width: "120px" }}
/>
</div>
<div className={styles.contactTime}>
<Skeleton.Input
active
size="small"
style={{ width: "40px" }}
/>
</div>
</div>
))}
</div>
</div>
</Sider>
{/* 主聊天区域骨架 */}
<Content className={styles.chatContent}>
{/* 聊天头部 */}
<div className={styles.chatHeader}>
<div className={styles.chatHeaderLeft}>
<Skeleton.Avatar active size="large" shape="circle" />
<Skeleton.Input active size="small" style={{ width: "100px" }} />
</div>
<div className={styles.chatHeaderRight}>
<Skeleton.Button active size="small" shape="circle" />
<Skeleton.Button active size="small" shape="circle" />
<Skeleton.Button active size="small" shape="circle" />
</div>
</div>
{/* 消息区域 */}
<div className={styles.messageArea}>
<div className={styles.loadingTip}>
<Spin indicator={antIcon} />
<span className={styles.loadingText}>
...
</span>
</div>
</div>
{/* 输入区域 */}
<div className={styles.inputArea}>
<div className={styles.inputToolbar}>
<Skeleton.Button active size="small" shape="circle" />
<Skeleton.Button active size="small" shape="circle" />
<Skeleton.Button active size="small" shape="circle" />
<Skeleton.Button active size="small" shape="circle" />
<Skeleton.Button active size="small" shape="circle" />
<Skeleton.Button active size="small" shape="circle" />
</div>
<div className={styles.inputField}>
<Skeleton.Input active size="large" block />
<Skeleton.Button active size="large" style={{ width: "60px" }} />
</div>
</div>
</Content>
{/* 右侧个人信息面板骨架 */}
<Sider width={300} className={styles.rightPanel}>
<div className={styles.profileSection}>
<div className={styles.profileHeader}>
<Skeleton.Avatar active size={80} shape="circle" />
<Skeleton.Input
active
size="small"
style={{ width: "100px", marginTop: "12px" }}
/>
<Skeleton.Input
active
size="small"
style={{ width: "60px", marginTop: "4px" }}
/>
</div>
<div className={styles.profileDetails}>
<div className={styles.detailItem}>
<Skeleton.Input active size="small" style={{ width: "60px" }} />
<Skeleton.Input
active
size="small"
style={{ width: "120px" }}
/>
</div>
<div className={styles.detailItem}>
<Skeleton.Input active size="small" style={{ width: "40px" }} />
<Skeleton.Input active size="small" style={{ width: "80px" }} />
</div>
<div className={styles.detailItem}>
<Skeleton.Input active size="small" style={{ width: "40px" }} />
<Skeleton.Input
active
size="small"
style={{ width: "100px" }}
/>
</div>
<div className={styles.detailItem}>
<Skeleton.Input active size="small" style={{ width: "40px" }} />
<Skeleton.Input
active
size="small"
style={{ width: "140px" }}
/>
</div>
</div>
<div className={styles.tagSection}>
<Skeleton.Input
active
size="small"
style={{ width: "40px", marginBottom: "12px" }}
/>
<div className={styles.tags}>
<Skeleton.Button
active
size="small"
style={{ width: "50px", marginRight: "8px" }}
/>
<Skeleton.Button
active
size="small"
style={{ width: "60px", marginRight: "8px" }}
/>
<Skeleton.Button
active
size="small"
style={{ width: "70px" }}
/>
</div>
</div>
</div>
</Sider>
</Layout>
</Layout>
);
};
export default PageSkeleton;

View File

@@ -1,83 +0,0 @@
.verticalUserList {
display: flex;
flex-direction: column;
height: 100%;
background-color: #ffffff;
.userListHeader {
padding: 10px 0;
text-align: center;
cursor: pointer;
.allFriends {
font-size: 12px;
color: #333333;
}
}
.userList {
flex: 1;
overflow-y: auto;
padding: 10px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: #555;
border-radius: 2px;
}
}
.userItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 0;
position: relative;
cursor: pointer;
background: #ffffff;
&.active {
.userAvatar {
border: 4px solid #1890ff;
.active & {
border-color: #1890ff;
}
}
}
}
.messageBadge {
:global(.ant-badge-count) {
background-color: #ff4d4f;
box-shadow: none;
font-size: 10px;
min-width: 16px;
height: 16px;
line-height: 16px;
padding: 0 4px;
border-radius: 8px;
}
}
.onlineIndicator {
position: absolute;
bottom: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid #2e2e2e;
&.online {
background-color: #52c41a; // 绿色表示在线
}
&.offline {
background-color: #8c8c8c; // 灰色表示离线
}
}
}

View File

@@ -1,65 +0,0 @@
import React from "react";
import { Avatar, Badge, Tooltip } from "antd";
import styles from "./VerticalUserList.module.scss";
import { useCkChatStore, asyncKfSelected } from "@/store/module/ckchat/ckchat";
import { TeamOutlined } from "@ant-design/icons";
const VerticalUserList: React.FC = () => {
// 格式化消息数量显示
const formatMessageCount = (count: number) => {
if (count > 99) return "99+";
return count.toString();
};
const handleUserSelect = (userId: number) => {
asyncKfSelected(userId);
};
const kfUserList = useCkChatStore(state => state.kfUserList);
const kfSelected = useCkChatStore(state => state.kfSelected);
return (
<div className={styles.verticalUserList}>
<div
className={styles.userListHeader}
onClick={() => handleUserSelect(0)}
>
<TeamOutlined style={{ fontSize: "26px" }} />
<div className={styles.allFriends}></div>
</div>
<div className={styles.userList}>
{kfUserList.map(user => (
<Tooltip key={user.id} title={user.name} placement="right">
<div
className={`${styles.userItem} ${kfSelected === user.id ? styles.active : ""}`}
onClick={() => handleUserSelect(user.id)}
>
<Badge
count={
user.messageCount ? formatMessageCount(user.messageCount) : 0
}
overflowCount={99}
className={styles.messageBadge}
>
<Avatar
src={user.avatar}
size={50}
className={styles.userAvatar}
style={{
backgroundColor: !user.avatar ? "#1890ff" : undefined,
}}
>
{!user.avatar && user.name.charAt(0)}
</Avatar>
</Badge>
<div
className={`${styles.onlineIndicator} ${user.isOnline ? styles.online : styles.offline}`}
/>
</div>
</Tooltip>
))}
</div>
</div>
);
};
export default VerticalUserList;

View File

@@ -1,323 +0,0 @@
// 消息列表数据接口 - 支持weChatGroup和contracts两种数据类型
export interface MessageListData {
serverId: number | string; // 服务器ID作为主键
id?: number; // 接口数据的原始ID字段
// 数据类型标识
dataType: "weChatGroup" | "contracts"; // 数据类型:微信群组或联系人
// 通用字段(两种类型都有的字段)
wechatAccountId: number; // 微信账号ID
tenantId: number; // 租户ID
accountId: number; // 账号ID
nickname: string; // 昵称
avatar?: string; // 头像
groupId: number; // 分组ID
config?: {
chat: boolean;
}; // 配置信息
labels?: string[]; // 标签列表
unreadCount: number; // 未读消息数
// 联系人特有字段当dataType为'contracts'时使用)
wechatId?: string; // 微信ID
alias?: string; // 别名
conRemark?: string; // 备注
quanPin?: string; // 全拼
gender?: number; // 性别
region?: string; // 地区
addFrom?: number; // 添加来源
phone?: string; // 电话
signature?: string; // 签名
extendFields?: any; // 扩展字段
city?: string; // 城市
lastUpdateTime?: string; // 最后更新时间
isPassed?: boolean; // 是否通过
thirdParty?: any; // 第三方
additionalPicture?: string; // 附加图片
desc?: string; // 描述
lastMessageTime?: number; // 最后消息时间
duplicate?: boolean; // 是否重复
// 微信群组特有字段当dataType为'weChatGroup'时使用)
chatroomId?: string; // 群聊ID
chatroomOwner?: string; // 群主
chatroomAvatar?: string; // 群头像
notice?: string; // 群公告
selfDisplyName?: string; // 自己在群里的显示名称
[key: string]: any; // 兼容其他字段
}
//联系人标签分组
export interface ContactGroupByLabel {
id: number;
accountId?: number;
groupName: string;
tenantId?: number;
count: number;
[key: string]: any;
}
//终端用户数据接口
export interface KfUserListData {
id: number;
tenantId: number;
wechatId: string;
nickname: string;
alias: string;
avatar: string;
gender: number;
region: string;
signature: string;
bindQQ: string;
bindEmail: string;
bindMobile: string;
createTime: string;
currentDeviceId: number;
isDeleted: boolean;
deleteTime: string;
groupId: number;
memo: string;
wechatVersion: string;
labels: string[];
lastUpdateTime: string;
isOnline?: boolean;
[key: string]: any;
}
// 账户信息接口
export interface CkAccount {
id: number;
realName: string;
nickname: string | null;
memo: string | null;
avatar: string;
userName: string;
secret: string;
accountType: number;
departmentId: number;
useGoogleSecretKey: boolean;
hasVerifyGoogleSecret: boolean;
}
//群聊数据接口
export interface weChatGroup {
id?: number;
wechatAccountId: number;
tenantId: number;
accountId: number;
chatroomId: string;
chatroomOwner: string;
conRemark: string;
nickname: string;
chatroomAvatar: string;
groupId: number;
config?: {
chat: boolean;
};
labels?: string[];
unreadCount: number;
notice: string;
selfDisplyName: string;
wechatChatroomId: number;
serverId?: number;
[key: string]: any;
}
// 联系人数据接口
export interface ContractData {
id?: number;
serverId?: number;
wechatAccountId: number;
wechatId: string;
alias: string;
conRemark: string;
nickname: string;
quanPin: string;
avatar?: string;
gender: number;
region: string;
addFrom: number;
phone: string;
labels: string[];
signature: string;
accountId: number;
extendFields: null;
city?: string;
lastUpdateTime: string;
isPassed: boolean;
tenantId: number;
groupId: number;
thirdParty: null;
additionalPicture: string;
desc: string;
config?: {
chat: boolean;
};
lastMessageTime: number;
unreadCount: number;
duplicate: boolean;
[key: string]: any;
}
//聊天记录接口
export interface ChatRecord {
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;
sender?: {
chatroomNickname: string;
isAdmin: boolean;
isDeleted: boolean;
nickname: string;
ownerWechatId: string;
wechatId: string;
[key: string]: any;
};
[key: string]: any;
}
/**
* 微信好友基本信息接口
* 包含主要字段和兼容性字段
*/
export interface WechatFriend {
// 主要字段
id: number; // 好友ID
wechatAccountId: number; // 微信账号ID
wechatId: string; // 微信ID
nickname: string; // 昵称
conRemark: string; // 备注名
avatar: string; // 头像URL
gender: number; // 性别1-男2-女0-未知
region: string; // 地区
phone: string; // 电话
labels: string[]; // 标签列表
[key: string]: any;
}
// 消息类型枚举
export enum MessageType {
TEXT = "text",
IMAGE = "image",
VOICE = "voice",
VIDEO = "video",
FILE = "file",
LOCATION = "location",
}
// 消息数据接口
export interface MessageData {
id: string;
senderId: string;
senderName: string;
content: string;
type: MessageType;
timestamp: string;
isRead: boolean;
replyTo?: string;
forwardFrom?: string;
}
// 聊天会话类型
export type ChatType = "private" | "group";
// 聊天会话接口
export interface ChatSession {
id: string;
type: ChatType;
name: string;
avatar?: string;
lastMessage: string;
lastTime: string;
unreadCount: number;
online: boolean;
members?: string[];
pinned?: boolean;
muted?: boolean;
}
// 聊天历史响应接口
export interface ChatHistoryResponse {
messages: MessageData[];
hasMore: boolean;
total: number;
}
// 发送消息请求接口
export interface SendMessageRequest {
chatId: string;
content: string;
type: MessageType;
replyTo?: string;
}
// 搜索联系人请求接口
export interface SearchContactRequest {
keyword: string;
limit?: number;
}
// 在线状态接口
export interface OnlineStatus {
userId: string;
online: boolean;
lastSeen: string;
}
// 消息状态接口
export interface MessageStatus {
messageId: string;
status: "sending" | "sent" | "delivered" | "read" | "failed";
timestamp: string;
}
// 文件上传响应接口
export interface FileUploadResponse {
url: string;
filename: string;
size: number;
type: string;
}
// 表情包接口
export interface EmojiData {
id: string;
name: string;
url: string;
category: string;
}
// 快捷回复接口
export interface QuickReply {
id: string;
content: string;
category: string;
useCount: number;
}
// 聊天设置接口
export interface ChatSettings {
autoReply: boolean;
autoReplyMessage: string;
notification: boolean;
sound: boolean;
theme: "light" | "dark";
fontSize: "small" | "medium" | "large";
}

View File

@@ -1,198 +0,0 @@
.ckboxLayout {
height: calc(100vh - 64px);
background: #fff;
display: flex;
flex-direction: column;
.header {
background: #1890ff;
color: #fff;
height: 64px;
line-height: 64px;
padding: 0 24px;
font-size: 18px;
font-weight: bold;
}
.verticalSider {
background: #2e2e2e;
border-right: 1px solid #f0f0f0;
overflow: hidden;
}
.sider {
background: #fff;
border-right: 1px solid #f0f0f0;
overflow: auto;
}
.sidebar {
height: 100%;
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 16px;
margin: 0;
&:hover {
color: #1890ff;
}
&.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: #1890ff;
}
}
}
:global(.ant-tabs-ink-bar) {
background: #1890ff;
}
}
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #8c8c8c;
p {
margin-top: 16px;
font-size: 14px;
}
}
}
.mainContent {
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow: auto;
.chatContainer {
height: 100%;
display: flex;
flex-direction: column;
.chatToolbar {
background: #fff;
border-bottom: 1px solid #f0f0f0;
padding: 8px 16px;
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 48px;
}
}
.welcomeScreen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
.welcomeContent {
text-align: center;
color: #8c8c8c;
h2 {
margin: 24px 0 12px 0;
color: #262626;
font-size: 24px;
font-weight: 600;
}
p {
font-size: 16px;
margin: 0;
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.ckboxLayout {
.sidebar {
.searchBar {
padding: 12px;
}
.tabs {
:global(.ant-tabs-nav) {
padding: 0 12px;
:global(.ant-tabs-tab) {
padding: 10px 12px;
}
}
}
}
.mainContent {
.chatContainer {
.chatToolbar {
padding: 6px 12px;
min-height: 40px;
}
}
.welcomeScreen {
.welcomeContent {
h2 {
font-size: 20px;
}
p {
font-size: 14px;
}
}
}
}
}
}

View File

@@ -1,89 +0,0 @@
import React, { useState, useEffect } from "react";
import { Layout } from "antd";
import { MessageOutlined } from "@ant-design/icons";
import ChatWindow from "./components/ChatWindow/index";
import SidebarMenu from "./components/SidebarMenu/index";
import VerticalUserList from "./components/VerticalUserList";
import PageSkeleton from "./components/Skeleton";
import styles from "./index.module.scss";
import { addChatSession } from "@/store/module/ckchat/ckchat";
const { Content, Sider } = Layout;
import { chatInitAPIdata, initSocket } from "./main";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { KfUserListData } from "@/pages/pc/ckbox/data";
const CkboxPage: React.FC = () => {
// 不要在组件初始化时获取sendCommand而是在需要时动态获取
const [loading, setLoading] = useState(false);
const currentContract = useWeChatStore(state => state.currentContract);
useEffect(() => {
// 方法一:使用 Promise 链式调用处理异步函数
setLoading(true);
chatInitAPIdata()
.then(response => {
const data = response as {
contractList: any[];
groupList: any[];
kfUserList: KfUserListData[];
newContractList: { groupName: string; contacts: any[] }[];
};
const { contractList } = data;
//找出已经在聊天的
const isChatList = contractList.filter(
v => (v?.config && v.config?.chat) || false,
);
isChatList.forEach(v => {
addChatSession(v);
});
// 数据加载完成后初始化WebSocket连接
initSocket();
})
.catch(error => {
console.error("获取联系人列表失败:", error);
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<PageSkeleton loading={loading}>
<Layout className={styles.ckboxLayout}>
<Layout>
{/* 垂直侧边栏 */}
<Sider width={80} className={styles.verticalSider}>
<VerticalUserList />
</Sider>
{/* 左侧联系人边栏 */}
<Sider width={280} className={styles.sider}>
<SidebarMenu loading={loading} />
</Sider>
{/* 主内容区 */}
<Content className={styles.mainContent}>
{currentContract ? (
<div className={styles.chatContainer}>
<ChatWindow contract={currentContract} />
</div>
) : (
<div className={styles.welcomeScreen}>
<div className={styles.welcomeContent}>
<MessageOutlined style={{ fontSize: 64, color: "#1890ff" }} />
<h2>使</h2>
<p></p>
</div>
</div>
)}
</Content>
</Layout>
</Layout>
</PageSkeleton>
);
};
export default CkboxPage;

View File

@@ -1,307 +0,0 @@
import {
asyncKfUserList,
asyncContractList,
asyncChatSessions,
asyncWeChatGroup,
asyncCountLables,
useCkChatStore,
} from "@/store/module/ckchat/ckchat";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import {
loginWithToken,
getControlTerminalList,
getContactList,
getGroupList,
} from "./api";
import { useUserStore } from "@/store/module/user";
import {
KfUserListData,
ContractData,
weChatGroup,
} from "@/pages/pc/ckbox/data";
import { WechatGroup } from "./api";
const { login2 } = useUserStore.getState();
//获取触客宝基础信息
export const chatInitAPIdata = async () => {
try {
//获取联系人列表
const contractList = await getAllContactList();
//获取联系人列表
asyncContractList(contractList);
//获取群列表
const groupList = await getAllGroupList();
await asyncWeChatGroup(groupList);
// 提取不重复的wechatAccountId组
const uniqueWechatAccountIds: number[] = getUniqueWechatAccountIds(
contractList,
groupList,
);
//获取控制终端列表
const kfUserList: KfUserListData[] =
await getControlTerminalListByWechatAccountIds(uniqueWechatAccountIds);
//获取用户列表
await asyncKfUserList(kfUserList);
//获取标签列表
const countLables = await getCountLables();
await asyncCountLables(countLables);
//获取消息会话列表并按lastUpdateTime排序
const filterUserSessions = contractList?.filter(
v => v?.config && v.config?.chat,
);
const filterGroupSessions = groupList?.filter(
v => v?.config && v.config?.chat,
);
//排序功能
const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
(a, b) => {
// 如果lastUpdateTime不存在则将其排在最后
if (!a.lastUpdateTime) return 1;
if (!b.lastUpdateTime) return -1;
// 首先按时间降序排列(最新的在前面)
const timeCompare =
new Date(b.lastUpdateTime).getTime() -
new Date(a.lastUpdateTime).getTime();
// 如果时间相同,则按未读消息数量降序排列
if (timeCompare === 0) {
// 如果unreadCount不存在则将其排在后面
const aUnread = a.unreadCount || 0;
const bUnread = b.unreadCount || 0;
return bUnread - aUnread; // 未读消息多的排在前面
}
return timeCompare;
},
);
//会话数据同步
asyncChatSessions(sortedSessions);
return {
contractList,
groupList,
kfUserList,
};
} catch (error) {
console.error("获取联系人列表失败:", error);
return [];
}
};
//发起soket连接
export const initSocket = () => {
// 检查WebSocket是否已经连接
const { status } = useWebSocketStore.getState();
// 如果已经连接或正在连接,则不重复连接
if (["connected", "connecting"].includes(status)) {
console.log("WebSocket已连接或正在连接跳过重复连接", { status });
return;
}
// 从store获取token和accountId
const { token2 } = useUserStore.getState();
const { getAccountId } = useCkChatStore.getState();
const Token = token2;
const accountId = getAccountId();
// 使用WebSocket store初始化连接
const { connect } = useWebSocketStore.getState();
// 连接WebSocket
connect({
accessToken: Token,
accountId: Number(accountId),
client: "kefu-client",
cmdType: "CmdSignIn",
seq: +new Date(),
});
};
export const getCountLables = async () => {
const LablesRes = await Promise.all(
[1, 2].map(item =>
WechatGroup({
groupType: item,
}),
),
);
const [friend, group] = LablesRes;
const countLables = [
...[
{
id: 0,
groupName: "默认群分组",
groupType: 2,
},
],
...group,
...friend,
...[
{
id: 0,
groupName: "未分组",
groupType: 1,
},
],
];
return countLables;
};
/**
* 根据标签组织联系人
* @param contractList 联系人列表
* @param countLables 标签列表
* @returns 按标签分组的联系人
*/
//获取控制终端列表
export const getControlTerminalListByWechatAccountIds = (
WechatAccountIds: number[],
) => {
return Promise.all(
WechatAccountIds.map(id => getControlTerminalList({ id: id })),
);
};
// 递归获取所有联系人列表
export const getAllContactList = async () => {
try {
let allContacts = [];
let prevId = 0;
const count = 1000;
let hasMore = true;
while (hasMore) {
const contractList = await getContactList({
prevId,
count,
});
if (
!contractList ||
!Array.isArray(contractList) ||
contractList.length === 0
) {
hasMore = false;
break;
}
allContacts = [...allContacts, ...contractList];
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
if (contractList.length < count) {
hasMore = false;
} else {
// 获取最后一条数据的id作为下一次请求的prevId
const lastContact = contractList[contractList.length - 1];
prevId = lastContact.id;
}
}
return allContacts;
} catch (error) {
console.error("获取所有联系人列表失败:", error);
return [];
}
};
// 提取不重复的wechatAccountId组
export const getUniqueWechatAccountIds = (
contacts: ContractData[],
groupList: weChatGroup[],
) => {
if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
return [];
}
// 使用Set来存储不重复的wechatAccountId
const uniqueAccountIdsSet = new Set<number>();
// 遍历联系人列表将每个wechatAccountId添加到Set中
contacts.forEach(contact => {
if (contact && contact.wechatAccountId) {
uniqueAccountIdsSet.add(contact.wechatAccountId);
}
});
// 遍历联系人列表将每个wechatAccountId添加到Set中
groupList.forEach(group => {
if (group && group.wechatAccountId) {
uniqueAccountIdsSet.add(group.wechatAccountId);
}
});
// 将Set转换为数组并返回
return Array.from(uniqueAccountIdsSet);
};
// 递归获取所有群列表
export const getAllGroupList = async () => {
try {
let allContacts = [];
let prevId = 0;
const count = 1000;
let hasMore = true;
while (hasMore) {
const contractList = await getGroupList({
prevId,
count,
});
if (
!contractList ||
!Array.isArray(contractList) ||
contractList.length === 0
) {
hasMore = false;
break;
}
allContacts = [...allContacts, ...contractList];
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
if (contractList.length < count) {
hasMore = false;
} else {
// 获取最后一条数据的id作为下一次请求的prevId
const lastContact = contractList[contractList.length - 1];
prevId = lastContact.id;
}
}
return allContacts;
} catch (error) {
console.error("获取所有群列表失败:", error);
return [];
}
};
//获取token
const getToken = () => {
return new Promise((resolve, reject) => {
const params = {
grant_type: "password",
password: "kr123456",
username: "kr_xf3",
// username: "karuo",
// password: "zhiqun1984",
};
loginWithToken(params)
.then(res => {
login2(res.access_token);
resolve(res.access_token);
})
.catch(err => {
reject(err);
});
});
};

View File

@@ -1,27 +0,0 @@
import CkboxPage from "@/pages/pc/ckbox";
import WeChatPage from "@/pages/pc/ckbox/weChat";
import Dashboard from "@/pages/pc/ckbox/dashboard";
const ckboxRoutes = [
{
path: "/ckbox",
element: <CkboxPage />,
auth: true,
children: [
{
path: "",
element: <Dashboard />,
},
{
path: "dashboard",
element: <Dashboard />,
},
{
path: "weChat",
element: <WeChatPage />,
},
],
},
];
export default ckboxRoutes;

View File

@@ -13,18 +13,18 @@ import {
} from "./data";
// 好友列表
export function getWechatFriendList() {
return request("/v1/kefu/wechatFriend/list", {}, "GET");
export function getWechatFriendList(params) {
return request("/v1/kefu/wechatFriend/list", params, "GET");
}
// 群列表
export function getWechatChatroomList() {
return request("/v1/kefu/wechatChatroom/list", {}, "GET");
export function getWechatChatroomList(params) {
return request("/v1/kefu/wechatChatroom/list", params, "GET");
}
//群、好友聊天记录列表
export function getChatroomList() {
return request("/v1/kefu/wechatChatroom/list", {}, "GET");
export function getMessageList() {
return request("/v1/kefu/message/list", {}, "GET");
}
//获取客服列表

View File

@@ -36,6 +36,26 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
);
};
// 提取content字符串中冒号后面的JSON部分
const extractJsonFromContent = (content: string): object | null => {
try {
// 查找第一个冒号的位置
const colonIndex = content.indexOf(":");
if (colonIndex === -1) {
return null;
}
// 提取冒号后面的部分并去除前后空格
const jsonStr = content.substring(colonIndex + 1).trim();
// 尝试解析JSON
return JSON.parse(jsonStr);
} catch (error) {
console.error("解析JSON失败:", error);
return null;
}
};
// 解析表情包文字格式[表情名称]并替换为img标签
const parseEmojiText = (text: string): React.ReactNode[] => {
const emojiRegex = /\[([^\]]+)\]/g;
@@ -193,6 +213,14 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
</div>
);
case 34: // 语音消息
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[语音消息 - 无效内容]");
}
// content直接是音频URL字符串
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
case 43: // 视频消息
return (
<VideoMessage content={content || ""} msg={msg} contract={contract} />
@@ -225,17 +253,16 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}
return renderErrorMessage("[表情包]");
case 34: // 语音消息
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[语音消息 - 无效内容]");
}
// content直接是音频URL字符串
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
case 49: // 小程序/文章/其他:图文、文件
return <SmallProgramMessage content={content || ""} />;
case 1090519089: {
// 文本消息
const extractedJson = extractJsonFromContent(content);
const newContent = extractedJson
? JSON.stringify(extractedJson)
: content;
return <SmallProgramMessage content={newContent || ""} />;
}
default: {
// 兼容旧版本和未知消息类型的处理逻辑
if (typeof content !== "string" || !content.trim()) {

View File

@@ -13,10 +13,7 @@ import {
getControlTerminalList,
getContactList,
getGroupList,
getAgentList,
getChatroomList,
getWechatFriendList,
getWechatChatroomList,
getMessageList,
} from "./api";
import { useUserStore } from "@/store/module/user";
@@ -33,7 +30,7 @@ const { login2 } = useUserStore.getState();
export const chatInitAPIdata = async () => {
try {
//获取联系人列表
const contractList = await getWechatFriendList();
const contractList = await getAllContactList();
//获取联系人列表
asyncContractList(contractList);
@@ -43,7 +40,15 @@ export const chatInitAPIdata = async () => {
await asyncWeChatGroup(groupList);
const kfUserList: KfUserListData[] = await getAgentList();
// 提取不重复的wechatAccountId组
const uniqueWechatAccountIds: number[] = getUniqueWechatAccountIds(
contractList,
groupList,
);
//获取控制终端列表
const kfUserList: KfUserListData[] =
await getControlTerminalListByWechatAccountIds(uniqueWechatAccountIds);
//获取用户列表
await asyncKfUserList(kfUserList);
@@ -53,36 +58,35 @@ export const chatInitAPIdata = async () => {
await asyncCountLables(countLables);
//获取消息会话列表并按lastUpdateTime排序
// const filterUserSessions = contractList?.filter(
// v => v?.config && v.config?.chat,
// );
// const filterGroupSessions = groupList?.filter(
// v => v?.config && v.config?.chat,
// );
const filterUserSessions = contractList?.filter(
v => v?.config && v.config?.chat,
);
const filterGroupSessions = groupList?.filter(
v => v?.config && v.config?.chat,
);
//排序功能
// const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
// (a, b) => {
// // 如果lastUpdateTime不存在则将其排在最后
// if (!a.lastUpdateTime) return 1;
// if (!b.lastUpdateTime) return -1;
const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
(a, b) => {
// 如果lastUpdateTime不存在则将其排在最后
if (!a.lastUpdateTime) return 1;
if (!b.lastUpdateTime) return -1;
// // 首先按时间降序排列(最新的在前面)
// const timeCompare =
// new Date(b.lastUpdateTime).getTime() -
// new Date(a.lastUpdateTime).getTime();
// 首先按时间降序排列(最新的在前面)
const timeCompare =
new Date(b.lastUpdateTime).getTime() -
new Date(a.lastUpdateTime).getTime();
// // 如果时间相同,则按未读消息数量降序排列
// if (timeCompare === 0) {
// // 如果unreadCount不存在则将其排在后面
// const aUnread = a.unreadCount || 0;
// const bUnread = b.unreadCount || 0;
// return bUnread - aUnread; // 未读消息多的排在前面
// }
// 如果时间相同,则按未读消息数量降序排列
if (timeCompare === 0) {
// 如果unreadCount不存在则将其排在后面
const aUnread = a.unreadCount || 0;
const bUnread = b.unreadCount || 0;
return bUnread - aUnread; // 未读消息多的排在前面
}
// return timeCompare;
// },
// );
const sortedSessions = await getChatroomList();
return timeCompare;
},
);
//会话数据同步
asyncChatSessions(sortedSessions);
@@ -103,7 +107,7 @@ export const initSocket = () => {
// 如果已经连接或正在连接,则不重复连接
if (["connected", "connecting"].includes(status)) {
// console.log("WebSocket已连接或正在连接跳过重复连接", { status });
console.log("WebSocket已连接或正在连接跳过重复连接", { status });
return;
}