refactor: 移除Cunkebao项目中未使用的微信聊天相关组件和代码
重构Touchkebao项目中的微信API调用逻辑,优化消息记录处理
This commit is contained in:
@@ -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");
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取联系人列表
|
||||
export const getContactList = (params: { prevId: string; count: number }) => {
|
||||
return request("/api/wechatFriend/list", params, "GET");
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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; /* 默认隐藏底部,如果需要显示可以移除此行 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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; // 灰色表示离线
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
//获取客服列表
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user