Merge branch 'yongpxu-dev' into develop
This commit is contained in:
@@ -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, // 将秒转换为毫秒
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
75
Touchkebao/src/pages/404/index.module.scss
Normal file
75
Touchkebao/src/pages/404/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
59
Touchkebao/src/pages/404/index.tsx
Normal file
59
Touchkebao/src/pages/404/index.tsx
Normal 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;
|
||||
@@ -89,6 +89,7 @@ const Login: React.FC = () => {
|
||||
const loginParams = {
|
||||
...values,
|
||||
verifySessionId: verify.verifySessionId,
|
||||
typeId: 1,
|
||||
};
|
||||
|
||||
const response =
|
||||
|
||||
@@ -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>
|
||||
、
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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("[小程序/文件消息]");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from "@/api/request2";
|
||||
export const getWechatAccountInfo = (params: { id?: string }) => {
|
||||
return request("/api/wechataccount", params, "GET");
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -13,6 +13,8 @@
|
||||
margin-bottom: 16px;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -53,7 +53,7 @@ const messageHandlers: Record<string, MessageHandler> = {
|
||||
}
|
||||
},
|
||||
CmdSendMessageResult: message => {
|
||||
updateMessage(message.friendMessageId, {
|
||||
updateMessage(message.friendMessageId || message.chatroomMessageId, {
|
||||
sendStatus: 0,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user