Merge branch 'yongpxu-dev' into develop

This commit is contained in:
超级老白兔
2025-11-08 18:05:15 +08:00
60 changed files with 5513 additions and 282 deletions

View File

@@ -10,7 +10,11 @@ import {
import { uploadFile } from "@/api/common";
interface AudioRecorderProps {
onAudioUploaded: (audioData: { url: string; durationMs: number }) => void;
onAudioUploaded: (audioData: {
url: string;
name: string;
durationMs?: number;
}) => void;
className?: string;
disabled?: boolean;
maxDuration?: number; // 最大录音时长(秒)
@@ -206,6 +210,7 @@ const AudioRecorder: React.FC<AudioRecorderProps> = ({
// 调用回调函数传递音频URL和时长毫秒
onAudioUploaded({
url: filePath,
name: audioFile.name,
durationMs: recordingTime * 1000, // 将秒转换为毫秒
});

View File

@@ -3,7 +3,7 @@ import React, { useRef } from "react";
import { message } from "antd";
interface SimpleFileUploadProps {
onFileUploaded?: (filePath: string) => void;
onFileUploaded?: (filePath: { name: string; url: string }) => void;
maxSize?: number; // 最大文件大小(MB)
type?: number; // 1: 图片, 2: 视频, 3: 音频, 4: 文件
slot?: React.ReactNode;
@@ -51,7 +51,10 @@ const SimpleFileUpload: React.FC<SimpleFileUploadProps> = ({
try {
const fileUrl = await uploadFile(file);
onFileUploaded?.(fileUrl);
onFileUploaded?.({
name: file.name,
url: fileUrl,
});
message.success("文件上传成功");
} catch (error: any) {
console.error("文件上传失败:", error);

View File

@@ -0,0 +1,75 @@
.not-found-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.not-found-content {
text-align: center;
max-width: 500px;
width: 100%;
}
.error-code {
font-size: 120px;
font-weight: bold;
color: #1890ff;
line-height: 1;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
@media (max-width: 768px) {
font-size: 80px;
}
}
.error-title {
font-size: 28px;
font-weight: 600;
color: #333;
margin: 20px 0 16px;
@media (max-width: 768px) {
font-size: 24px;
}
}
.error-description {
font-size: 16px;
color: #666;
margin-bottom: 40px;
line-height: 1.6;
@media (max-width: 768px) {
font-size: 14px;
margin-bottom: 30px;
}
}
.action-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
gap: 12px;
}
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
min-width: 140px;
@media (max-width: 768px) {
width: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,59 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "antd-mobile";
import { ArrowLeftOutlined, HomeOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import styles from "./index.module.scss";
const NotFound: React.FC = () => {
const navigate = useNavigate();
const handleGoHome = () => {
navigate("/");
};
const handleGoBack = () => {
navigate(-1);
};
return (
<Layout>
<div className={styles["not-found-container"]}>
<div className={styles["not-found-content"]}>
{/* 404 图标 */}
<div className={styles["error-code"]}>404</div>
{/* 错误提示 */}
<h1 className={styles["error-title"]}></h1>
<p className={styles["error-description"]}>
访
</p>
{/* 操作按钮 */}
<div className={styles["action-buttons"]}>
<Button
color="primary"
size="large"
onClick={handleGoHome}
className={styles["action-btn"]}
>
<HomeOutlined />
<span></span>
</Button>
<Button
color="default"
size="large"
onClick={handleGoBack}
className={styles["action-btn"]}
>
<ArrowLeftOutlined />
<span></span>
</Button>
</div>
</div>
</div>
</Layout>
);
};
export default NotFound;

View File

@@ -89,6 +89,7 @@ const Login: React.FC = () => {
const loginParams = {
...values,
verifySessionId: verify.verifySessionId,
typeId: 1,
};
const response =

View File

@@ -34,7 +34,6 @@ const { sendCommand } = useWebSocketStore.getState();
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const [inputValue, setInputValue] = useState("");
const [showMaterialModal, setShowMaterialModal] = useState(false);
const EnterModule = useWeChatStore(state => state.EnterModule);
const updateShowCheckbox = useWeChatStore(state => state.updateShowCheckbox);
const updateEnterModule = useWeChatStore(state => state.updateEnterModule);
@@ -254,20 +253,43 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
FILE: 5,
};
const handleFileUploaded = (
filePath: string | { url: string; durationMs: number },
filePath: { url: string; name: string; durationMs?: number },
fileType: number,
) => {
console.log("handleFileUploaded: ", fileType, filePath);
// msgType(1:文本 3:图片 43:视频 47:动图表情包gif、其他表情包 49:小程序/其他:图文、文件)
let msgType = 1;
let content: any = "";
if ([FileType.TEXT].includes(fileType)) {
msgType = getMsgTypeByFileFormat(filePath as string);
msgType = getMsgTypeByFileFormat(filePath.url);
} else if ([FileType.IMAGE].includes(fileType)) {
msgType = 3;
content = filePath.url;
} else if ([FileType.AUDIO].includes(fileType)) {
msgType = 34;
content = JSON.stringify({
url: filePath.url,
durationMs: filePath.durationMs,
});
} else if ([FileType.FILE].includes(fileType)) {
msgType = 49;
msgType = getMsgTypeByFileFormat(filePath.url);
if (msgType === 3) {
content = filePath.url;
}
if (msgType === 43) {
content = filePath.url;
}
if (msgType === 49) {
content = JSON.stringify({
type: "file",
title: filePath.name,
url: filePath.url,
});
}
}
const messageId = +Date.now();
const params = {
wechatAccountId: contract.wechatAccountId,
@@ -275,10 +297,37 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType,
content: [FileType.AUDIO].includes(fileType)
? JSON.stringify(filePath)
: filePath,
content: content,
seq: messageId,
};
// 构造本地消息对象
const localMessage: ChatRecord = {
id: messageId, // 使用时间戳作为临时ID
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: params.content,
msgType: msgType,
msgSubType: 0,
msgSvrId: "",
isSend: true, // 标记为发送中
createTime: new Date().toISOString(),
isDeleted: false,
deleteTime: "",
sendStatus: 1,
wechatTime: Date.now(),
origin: 0,
msgId: 0,
recalled: false,
seq: messageId,
};
// 先插入本地数据
addMessage(localMessage);
sendCommand("CmdSendMessage", params);
};
@@ -349,10 +398,10 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
<div className={styles.leftTool}>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.FILE)
onFileUploaded={fileInfo =>
handleFileUploaded(fileInfo, FileType.FILE)
}
maxSize={1}
maxSize={10}
type={4}
slot={
<Button
@@ -363,10 +412,10 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
}
/>
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.IMAGE)
onFileUploaded={fileInfo =>
handleFileUploaded(fileInfo, FileType.IMAGE)
}
maxSize={1}
maxSize={10}
type={1}
slot={
<Button
@@ -379,7 +428,14 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
<AudioRecorder
onAudioUploaded={audioData =>
handleFileUploaded(audioData, FileType.AUDIO)
handleFileUploaded(
{
name: audioData.name,
url: audioData.url,
durationMs: audioData.durationMs,
},
FileType.AUDIO,
)
}
className={styles.toolbarButton}
/>
@@ -462,87 +518,7 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
</>
)}
</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>
</>
);
};

View File

@@ -26,7 +26,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
const messageData = JSON.parse(trimmedContent);
// 处理文章类型消息
if (messageData.type === "link" && messageData.title && messageData.url) {
if (messageData.type === "link") {
const { title, desc, thumbPath, url } = messageData;
return (
@@ -68,7 +68,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
}
// 处理小程序消息 - 统一使用parseWeappMsgStr解析
if (messageData.type === "miniprogram" && messageData.contentXml) {
if (messageData.type === "miniprogram") {
try {
const parsedData = parseWeappMsgStr(trimmedContent);
@@ -144,109 +144,115 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
}
}
// 验证传统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}
//处理文档类型消息
if (messageData.type === "file") {
const { url, title } = messageData;
// 增强的文件消息处理
const isFileUrl =
url.startsWith("http") ||
url.startsWith("https") ||
url.startsWith("file://") ||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(url);
if (isFileUrl) {
// 尝试从URL中提取文件名
const fileName =
title || url.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(messageData.url, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
}}
>
</div>
</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>
);
// 验证传统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>
// );
// }
}
return renderErrorMessage("[小程序/文件消息]");

View File

@@ -39,6 +39,7 @@ const TransmitModal: React.FC = () => {
// 从 Zustand store 获取更新方法
const openTransmitModal = useContactStore(state => state.openTransmitModal);
const setTransmitModal = useContactStore(state => state.setTransmitModal);
const updateSelectedChatRecords = useWeChatStore(
state => state.updateSelectedChatRecords,
@@ -142,11 +143,11 @@ const TransmitModal: React.FC = () => {
<Modal
title="转发消息"
open={openTransmitModal}
onCancel={() => updateTransmitModal(false)}
onCancel={() => setTransmitModal(false)}
width={"60%"}
className={styles.transmitModal}
footer={[
<Button key="cancel" onClick={() => updateTransmitModal(false)}>
<Button key="cancel" onClick={() => setTransmitModal(false)}>
</Button>,
<Button

View File

@@ -57,24 +57,22 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
// 如果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 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>
);
@@ -109,24 +107,22 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
// 如果已有视频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 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>
);
@@ -134,38 +130,36 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
// 显示预览图,根据加载状态显示不同的图标
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 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>

View File

@@ -0,0 +1,4 @@
import request from "@/api/request2";
export const getWechatAccountInfo = (params: { id?: string }) => {
return request("/api/wechataccount", params, "GET");
};

View File

@@ -0,0 +1,102 @@
.addFriendModal {
.ant-modal-body {
padding: 24px;
}
.ant-modal-header {
display: none;
}
}
.modalContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.searchInputWrapper {
.ant-input {
height: 40px;
border-radius: 4px;
}
}
.tipText {
font-size: 14px;
color: #666;
margin-top: 8px;
margin-bottom: 8px;
}
.greetingWrapper {
margin-bottom: 20px;
.ant-input {
resize: none;
border-radius: 4px;
}
}
.formRow {
display: flex;
align-items: center;
margin-bottom: 16px;
.label {
width: 60px;
font-size: 14px;
color: #333;
flex-shrink: 0;
}
.inputField {
flex: 1;
height: 36px;
border-radius: 4px;
}
.selectField {
flex: 1;
border-radius: 4px;
.ant-select-selector {
height: 36px;
border-radius: 4px;
}
}
}
.buttonGroup {
display: flex;
gap: 12px;
margin-top: 8px;
justify-content: flex-start;
.addButton {
background-color: #52c41a;
border-color: #52c41a;
color: #fff;
height: 36px;
padding: 0 24px;
border-radius: 4px;
font-size: 14px;
&:hover {
background-color: #73d13d;
border-color: #73d13d;
}
}
.cancelButton {
height: 36px;
padding: 0 24px;
border-radius: 4px;
font-size: 14px;
border-color: #d9d9d9;
color: #333;
&:hover {
border-color: #40a9ff;
color: #40a9ff;
}
}
}

View File

@@ -0,0 +1,174 @@
import React, { useState } from "react";
import { Modal, Input, Button, message, Select } from "antd";
import { SearchOutlined, DownOutlined } from "@ant-design/icons";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { useCustomerStore } from "@/store/module/weChat/customer";
import styles from "./index.module.scss";
interface AddFriendsProps {
visible: boolean;
onCancel: () => void;
}
const AddFriends: React.FC<AddFriendsProps> = ({ visible, onCancel }) => {
const [searchValue, setSearchValue] = useState("");
const [greeting, setGreeting] = useState("我是老坑爹-解放双手,释放时间");
const [remark, setRemark] = useState("");
const [selectedTag, setSelectedTag] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const { sendCommand } = useWebSocketStore();
const currentCustomer = useCustomerStore(state => state.currentCustomer);
// 获取标签列表(从 currentCustomer.labels 字符串数组)
const tags = currentCustomer?.labels || [];
// 重置表单
const handleReset = () => {
setSearchValue("");
setGreeting("我是老坑爹-解放双手,释放时间");
setRemark("");
setSelectedTag(undefined);
};
// 处理取消
const handleCancel = () => {
handleReset();
onCancel();
};
// 判断是否为手机号11位数字
const isPhoneNumber = (value: string): boolean => {
return /^1[3-9]\d{9}$/.test(value.trim());
};
// 处理添加好友
const handleAddFriend = async () => {
if (!searchValue.trim()) {
message.warning("请输入微信号或手机号");
return;
}
if (!currentCustomer?.id) {
message.error("请先选择客服账号");
return;
}
setLoading(true);
try {
const trimmedValue = searchValue.trim();
const isPhone = isPhoneNumber(trimmedValue);
// 发送添加好友命令
sendCommand("CmdSendFriendRequest", {
WechatAccountId: currentCustomer.id,
TargetWechatId: isPhone ? "" : trimmedValue,
Phone: isPhone ? trimmedValue : "",
Message: greeting.trim() || "我是老坑爹-解放双手,释放时间",
Remark: remark.trim() || "",
Labels: selectedTag || "",
});
message.success("好友请求已发送");
handleCancel();
} catch (error) {
console.error("添加好友失败:", error);
message.error("添加好友失败,请重试");
} finally {
setLoading(false);
}
};
return (
<Modal
title={null}
open={visible}
onCancel={handleCancel}
footer={null}
className={styles.addFriendModal}
width={480}
closable={false}
>
<div className={styles.modalContent}>
{/* 搜索输入框 */}
<div className={styles.searchInputWrapper}>
<Input
placeholder="请输入微信号/手机号"
prefix={<SearchOutlined />}
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
onPressEnter={handleAddFriend}
disabled={loading}
allowClear
/>
</div>
{/* 提示文字 */}
<div className={styles.tipText}>,</div>
{/* 验证消息文本区域 */}
<div className={styles.greetingWrapper}>
<Input.TextArea
value={greeting}
onChange={e => setGreeting(e.target.value)}
rows={4}
disabled={loading}
maxLength={200}
/>
</div>
{/* 备注输入框 */}
<div className={styles.formRow}>
<span className={styles.label}>:</span>
<Input
value={remark}
onChange={e => setRemark(e.target.value)}
placeholder="请输入备注"
disabled={loading}
className={styles.inputField}
/>
</div>
{/* 标签选择器 */}
<div className={styles.formRow}>
<span className={styles.label}>:</span>
<Select
value={selectedTag}
onChange={setSelectedTag}
placeholder="请选择标签"
disabled={loading}
className={styles.selectField}
suffixIcon={<DownOutlined />}
allowClear
>
{tags.map(tag => (
<Select.Option key={tag} value={tag}>
{tag}
</Select.Option>
))}
</Select>
</div>
{/* 底部按钮 */}
<div className={styles.buttonGroup}>
<Button
type="primary"
onClick={handleAddFriend}
loading={loading}
className={styles.addButton}
>
</Button>
<Button
onClick={handleCancel}
disabled={loading}
className={styles.cancelButton}
>
</Button>
</div>
</div>
</Modal>
);
};
export default AddFriends;

View File

@@ -0,0 +1,153 @@
.popChatRoomModal {
.ant-modal-content {
height: 500px;
}
.ant-modal-body {
height: calc(500px - 110px);
overflow: hidden;
padding: 16px;
}
}
.modalContent {
height: 100%;
display: flex;
flex-direction: column;
}
.searchContainer {
margin-bottom: 16px;
.searchInput {
width: 100%;
}
}
.contentBody {
flex: 1;
display: flex;
gap: 16px;
min-height: 0;
}
.contactList,
.selectedList {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #d9d9d9;
border-radius: 6px;
overflow: hidden;
}
.listHeader {
padding: 12px 16px;
background-color: #fafafa;
border-bottom: 1px solid #d9d9d9;
font-weight: 500;
font-size: 14px;
color: #262626;
}
.listContent {
flex: 1;
overflow-y: auto;
padding: 8px;
min-height: 0;
}
.contactItem,
.selectedItem {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 4px;
transition: background-color 0.2s;
&:hover {
background-color: #f5f5f5;
}
&:last-child {
margin-bottom: 0;
}
}
.paginationContainer {
padding: 12px;
border-top: 1px solid #d9d9d9;
background-color: #fafafa;
}
.selectedItem {
justify-content: space-between;
}
.contactInfo {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.contactName {
font-size: 14px;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conRemark {
font-size: 12px;
color: #8c8c8c;
}
.removeIcon {
color: #8c8c8c;
cursor: pointer;
padding: 4px;
border-radius: 2px;
transition: all 0.2s;
&:hover {
color: #ff4d4f;
background-color: #fff2f0;
}
}
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
flex-direction: column;
gap: 12px;
color: #8c8c8c;
}
// 响应式设计
@media (max-width: 768px) {
.popChatRoomModal {
.ant-modal-content {
max-height: 600px;
}
.ant-modal-body {
max-height: calc(600px - 110px);
}
}
.contentBody {
flex-direction: column;
gap: 12px;
}
.contactList,
.selectedList {
min-height: 200px;
}
}

View File

@@ -0,0 +1,348 @@
import React, { useState, useEffect, useMemo } from "react";
import {
Modal,
Input,
Button,
Avatar,
Checkbox,
Empty,
Spin,
message,
Pagination,
} from "antd";
import { SearchOutlined, CloseOutlined, UserOutlined } from "@ant-design/icons";
import styles from "./index.module.scss";
import { ContactManager } from "@/utils/dbAction";
import { useUserStore } from "@/store/module/user";
import { useCustomerStore } from "@/store/module/weChat/customer";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { Contact } from "@/utils/db";
interface PopChatRoomProps {
visible: boolean;
onCancel: () => void;
}
const PopChatRoom: React.FC<PopChatRoomProps> = ({ visible, onCancel }) => {
const [searchValue, setSearchValue] = useState("");
const [allContacts, setAllContacts] = useState<Contact[]>([]);
const [selectedContacts, setSelectedContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [showNameModal, setShowNameModal] = useState(false);
const [chatroomName, setChatroomName] = useState("");
const pageSize = 10;
const { sendCommand } = useWebSocketStore();
const currentUserId = useUserStore(state => state.user?.id) || 0;
const currentCustomer = useCustomerStore(state => state.currentCustomer);
// 加载联系人数据(只加载好友,不包含群聊)
const loadContacts = async () => {
setLoading(true);
try {
const allContactsData =
await ContactManager.getUserContacts(currentUserId);
// 过滤出好友类型,排除群聊
const friendsOnly = (allContactsData as Contact[]).filter(
contact => contact.type === "friend",
);
setAllContacts(friendsOnly);
} catch (err) {
console.error("加载联系人数据失败:", err);
message.error("加载联系人数据失败");
} finally {
setLoading(false);
}
};
// 重置状态
useEffect(() => {
if (visible) {
setSearchValue("");
setSelectedContacts([]);
setPage(1);
loadContacts();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
// 过滤联系人 - 支持名称和拼音搜索
const filteredContacts = useMemo(() => {
if (!searchValue.trim()) return allContacts;
const keyword = searchValue.toLowerCase();
return allContacts.filter(contact => {
const name = (contact.nickname || "").toLowerCase();
const remark = (contact.conRemark || "").toLowerCase();
const quanPin = (contact as any).quanPin?.toLowerCase?.() || "";
const pinyin = (contact as any).pinyin?.toLowerCase?.() || "";
return (
name.includes(keyword) ||
remark.includes(keyword) ||
quanPin.includes(keyword) ||
pinyin.includes(keyword)
);
});
}, [allContacts, searchValue]);
const paginatedContacts = useMemo(() => {
const start = (page - 1) * pageSize;
const end = start + pageSize;
return filteredContacts.slice(start, end);
}, [filteredContacts, page]);
// 处理联系人选择
const handleContactSelect = (contact: Contact) => {
setSelectedContacts(prev => {
if (isContactSelected(contact.id)) {
return prev.filter(item => item.id !== contact.id);
}
return [...prev, contact];
});
};
// 移除已选择的联系人
const handleRemoveSelected = (contactId: number) => {
setSelectedContacts(prev =>
prev.filter(contact => contact.id !== contactId),
);
};
// 检查联系人是否已选择
const isContactSelected = (contactId: number) => {
return selectedContacts.some(contact => contact.id === contactId);
};
// 处理取消
const handleCancel = () => {
setSearchValue("");
setSelectedContacts([]);
setPage(1);
setChatroomName("");
setShowNameModal(false);
onCancel();
};
// 处理创建群聊 - 先显示输入群名称的弹窗
const handleCreateGroup = () => {
if (selectedContacts.length === 0) {
message.warning("请至少选择一个联系人");
return;
}
if (!currentCustomer?.id) {
message.error("请先选择客服账号");
return;
}
// 显示输入群名称的弹窗
setShowNameModal(true);
};
// 确认创建群聊
const handleConfirmCreate = () => {
if (!chatroomName.trim()) {
message.warning("请输入群聊名称");
return;
}
if (!currentCustomer?.id) {
message.error("请先选择客服账号");
return;
}
// 获取选中的好友ID列表
const friendIds = selectedContacts.map(contact => contact.id);
try {
// 发送创建群聊命令
sendCommand("CmdChatroomCreate", {
wechatAccountId: currentCustomer.id,
chatroomName: chatroomName.trim(),
wechatFriendIds: friendIds,
});
message.success("群聊创建请求已发送");
handleCancel();
} catch (error) {
console.error("创建群聊失败:", error);
message.error("创建群聊失败,请重试");
}
};
// 取消输入群名称
const handleCancelNameInput = () => {
setShowNameModal(false);
setChatroomName("");
};
return (
<>
<Modal
title="发起群聊"
open={visible}
onCancel={handleCancel}
width="60%"
className={styles.popChatRoomModal}
footer={[
<Button key="cancel" onClick={handleCancel}>
</Button>,
<Button
key="create"
type="primary"
onClick={handleCreateGroup}
disabled={selectedContacts.length === 0}
loading={loading}
>
{selectedContacts.length > 0 && ` (${selectedContacts.length})`}
</Button>,
]}
>
<div className={styles.modalContent}>
{/* 搜索框 */}
<div className={styles.searchContainer}>
<Input
placeholder="请输入昵称/微信号 搜索好友"
prefix={<SearchOutlined />}
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
className={styles.searchInput}
disabled={loading}
allowClear
/>
</div>
<div className={styles.contentBody}>
{/* 左侧联系人列表 */}
<div className={styles.contactList}>
<div className={styles.listHeader}>
<span> ({filteredContacts.length})</span>
</div>
<div className={styles.listContent}>
{loading ? (
<div className={styles.loadingContainer}>
<Spin size="large" />
<span>...</span>
</div>
) : filteredContacts.length > 0 ? (
paginatedContacts.map(contact => (
<div key={contact.id} className={styles.contactItem}>
<Checkbox
checked={isContactSelected(contact.id)}
onChange={() => handleContactSelect(contact)}
>
<div className={styles.contactInfo}>
<Avatar
size={32}
src={contact.avatar}
icon={<UserOutlined />}
/>
<div className={styles.contactName}>
<div>{contact.nickname}</div>
{contact.conRemark && (
<div className={styles.conRemark}>
{contact.conRemark}
</div>
)}
</div>
</div>
</Checkbox>
</div>
))
) : (
<Empty
description={
searchValue ? "未找到匹配的联系人" : "暂无联系人"
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</div>
{filteredContacts.length > 0 && (
<div className={styles.paginationContainer}>
<Pagination
size="small"
current={page}
pageSize={pageSize}
total={filteredContacts.length}
onChange={p => setPage(p)}
showSizeChanger={false}
/>
</div>
)}
</div>
{/* 右侧已选择列表 */}
<div className={styles.selectedList}>
<div className={styles.listHeader}>
<span> ({selectedContacts.length})</span>
</div>
<div className={styles.listContent}>
{selectedContacts.length > 0 ? (
selectedContacts.map(contact => (
<div key={contact.id} className={styles.selectedItem}>
<div className={styles.contactInfo}>
<Avatar
size={32}
src={contact.avatar}
icon={<UserOutlined />}
/>
<div className={styles.contactName}>
<div>{contact.nickname}</div>
{contact.conRemark && (
<div className={styles.conRemark}>
{contact.conRemark}
</div>
)}
</div>
</div>
<CloseOutlined
className={styles.removeIcon}
onClick={() => handleRemoveSelected(contact.id)}
/>
</div>
))
) : (
<Empty
description="请选择联系人"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</div>
</div>
</div>
</div>
</Modal>
{/* 输入群名称的弹窗 */}
<Modal
title="提示"
open={showNameModal}
onCancel={handleCancelNameInput}
footer={[
<Button key="cancel" onClick={handleCancelNameInput}>
</Button>,
<Button key="confirm" type="primary" onClick={handleConfirmCreate}>
</Button>,
]}
>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}></div>
<Input
placeholder="请输入群聊名称"
value={chatroomName}
onChange={e => setChatroomName(e.target.value)}
onPressEnter={handleConfirmCreate}
autoFocus
/>
</div>
</Modal>
</>
);
};
export default PopChatRoom;

View File

@@ -13,6 +13,8 @@
margin-bottom: 16px;
padding: 0;
background: #fff;
display: flex;
gap: 10px;
}
.tabsContainer {

View File

@@ -1,9 +1,16 @@
import React, { useState, useEffect } from "react";
import { Input, Skeleton } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { Input, Skeleton, Button, Dropdown, MenuProps } from "antd";
import {
SearchOutlined,
PlusOutlined,
UserAddOutlined,
TeamOutlined,
} from "@ant-design/icons";
import WechatFriends from "./WechatFriends";
import MessageList from "./MessageList/index";
import FriendsCircle from "./FriendsCicle";
import AddFriends from "./AddFriends";
import PopChatRoom from "./PopChatRoom";
import styles from "./SidebarMenu.module.scss";
import { useContactStore } from "@/store/module/weChat/contacts";
import { useCustomerStore } from "@/store/module/weChat/customer";
@@ -28,6 +35,9 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
const [activeTab, setActiveTab] = useState("chats");
const [switchingTab, setSwitchingTab] = useState(false); // tab切换加载状态
const [isAddFriendModalVisible, setIsAddFriendModalVisible] = useState(false);
const [isCreateGroupModalVisible, setIsCreateGroupModalVisible] =
useState(false);
// 监听 currentContact 变化自动切换到聊天tab并选中会话
useEffect(() => {
@@ -68,6 +78,26 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
clearSearchKeyword();
};
// 下拉菜单项
const menuItems: MenuProps["items"] = [
{
key: "addFriend",
label: "添加好友",
icon: <UserAddOutlined />,
onClick: () => {
setIsAddFriendModalVisible(true);
},
},
{
key: "createGroup",
label: "发起群聊",
icon: <TeamOutlined />,
onClick: () => {
setIsCreateGroupModalVisible(true);
},
},
];
// 渲染骨架屏
const renderSkeleton = () => (
<div className={styles.skeletonContainer}>
@@ -126,6 +156,15 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
onClear={handleClearSearch}
allowClear
/>
{currentCustomer && (
<Dropdown
menu={{ items: menuItems }}
trigger={["click"]}
placement="bottomRight"
>
<Button type="primary" icon={<PlusOutlined />}></Button>
</Dropdown>
)}
</div>
{/* 标签页切换 */}
@@ -181,6 +220,16 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
<div className={styles.sidebarMenu}>
{renderHeader()}
<div className={styles.contentContainer}>{renderContent()}</div>
{/* 添加好友弹窗 */}
<AddFriends
visible={isAddFriendModalVisible}
onCancel={() => setIsAddFriendModalVisible(false)}
/>
{/* 发起群聊弹窗 */}
<PopChatRoom
visible={isCreateGroupModalVisible}
onCancel={() => setIsCreateGroupModalVisible(false)}
/>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import React from "react";
import { BrowserRouter, useRoutes, RouteObject } from "react-router-dom";
import PermissionRoute from "./permissionRoute";
import NotFound from "@/pages/404";
// 动态导入所有 module 下的 ts/tsx 路由模块
const modules = import.meta.glob("./module/*.{ts,tsx}", { eager: true });
@@ -31,7 +32,15 @@ function wrapWithPermission(
return route;
}
const routes = allRoutes.map(wrapWithPermission);
// 添加 404 路由(通配符路由,必须放在最后)
const routes = [
...allRoutes.map(wrapWithPermission),
{
path: "*",
element: <NotFound />,
auth: false,
},
];
const AppRoutes = () => useRoutes(routes);

View File

@@ -31,7 +31,7 @@ import {
* 微信聊天状态管理 Store
* 使用 Zustand 管理微信聊天相关的状态和操作
*/
export const useWeChatStore = create<WeChatState>()(
export const useDataCenterStore = create<WeChatState>()(
persist(
(set, get) => ({
showChatRecordModel: false,
@@ -153,7 +153,7 @@ export const useWeChatStore = create<WeChatState>()(
contract: ContractData | weChatGroup,
isExist?: boolean,
) => {
const state = useWeChatStore.getState();
const state = useDataCenterStore.getState();
// 切换联系人时清空当前消息,等待重新加载
set({ currentMessages: [], openTransmitModal: false });
@@ -193,7 +193,7 @@ export const useWeChatStore = create<WeChatState>()(
// ==================== 消息加载方法 ====================
/** 加载聊天消息 */
loadChatMessages: async (Init: boolean, To?: number) => {
const state = useWeChatStore.getState();
const state = useDataCenterStore.getState();
const contact = state.currentContract;
set({ messagesLoading: true });
set({ isLoadingData: Init });
@@ -258,7 +258,7 @@ export const useWeChatStore = create<WeChatState>()(
keyword: string;
Count?: number;
}) => {
const state = useWeChatStore.getState();
const state = useDataCenterStore.getState();
const contact = state.currentContract;
set({ messagesLoading: true });
@@ -302,7 +302,7 @@ export const useWeChatStore = create<WeChatState>()(
// ==================== 消息接收处理 ====================
/** 接收新消息处理 */
receivedMsg: async message => {
const currentContract = useWeChatStore.getState().currentContract;
const currentContract = useDataCenterStore.getState().currentContract;
// 判断是群聊还是私聊
const getMessageId =
message?.wechatChatroomId || message.wechatFriendId;
@@ -468,13 +468,16 @@ export const useWeChatStore = create<WeChatState>()(
// ==================== 便捷选择器导出 ====================
/** 获取当前联系人的 Hook */
export const useCurrentContact = () =>
useWeChatStore(state => state.currentContract);
useDataCenterStore(state => state.currentContract);
/** 获取当前消息列表的 Hook */
export const useCurrentMessages = () =>
useWeChatStore(state => state.currentMessages);
useDataCenterStore(state => state.currentMessages);
/** 获取消息加载状态的 Hook */
export const useMessagesLoading = () =>
useWeChatStore(state => state.messagesLoading);
useDataCenterStore(state => state.messagesLoading);
/** 获取复选框显示状态的 Hook */
export const useShowCheckbox = () =>
useWeChatStore(state => state.showCheckbox);
useDataCenterStore(state => state.showCheckbox);
export const useUpdateTransmitModal = (open: boolean) =>
useDataCenterStore(state => state.updateTransmitModal(open));

View File

@@ -53,7 +53,7 @@ const messageHandlers: Record<string, MessageHandler> = {
}
},
CmdSendMessageResult: message => {
updateMessage(message.friendMessageId, {
updateMessage(message.friendMessageId || message.chatroomMessageId, {
sendStatus: 0,
});
},