feat(聊天窗口): 重构消息输入组件并添加微信风格样式

将消息输入功能从ChatWindow组件中拆分为独立的MessageEnter组件
添加微信风格的样式和交互效果
新增消息处理逻辑和素材选择功能
重命名getMessages为getChatMessages以保持命名一致性
This commit is contained in:
超级老白兔
2025-09-02 15:21:54 +08:00
parent b3f6d4f6e7
commit d5e609aa37
6 changed files with 517 additions and 440 deletions

View File

@@ -23,7 +23,7 @@ export function clearUnreadCount(params) {
return request("/api/WechatFriend/clearUnreadCount", params, "PUT");
}
//获取聊天记录-2 获取列表
export function getMessages(params: {
export function getChatMessages(params: {
wechatAccountId: number;
wechatFriendId?: number;
wechatChatroomId?: number;
@@ -73,19 +73,6 @@ export const getControlTerminalList = params => {
return request("/api/wechataccount", params, "GET");
};
// 搜索联系人
export const getChatMessage = (params: {
wechatAccountId: number;
wechatFriendId: number;
From: number;
To: number;
Count: number;
olderData: boolean;
keyword: string;
}) => {
return request("/api/FriendMessage/SearchMessage", params, "GET");
};
// 获取聊天历史
export const getChatHistory = (
chatId: string,

View File

@@ -128,127 +128,8 @@
}
}
.chatFooter {
background: #fff;
border-top: 1px solid #f0f0f0;
padding: 0;
height: auto;
min-height: auto;
flex-shrink: 0;
.inputContainer {
.inputToolbar {
display: flex;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
justify-content: space-between;
.leftTool {
display: flex;
gap: 4px;
}
.rightTool {
display: flex;
gap: 8px;
padding: 8px;
}
.toolbarButton {
color: #666;
border: none;
padding: 8px;
border-radius: 4px;
font-size: 18px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
transition: all 0.2s;
&:hover {
color: #1890ff;
background: #e6f7ff;
}
&:active {
background: #bae7ff;
}
}
}
.inputArea {
display: flex;
padding: 12px 16px;
gap: 8px;
align-items: flex-end;
background: #fff;
.messageInput {
flex: 1;
border: 1px solid #d9d9d9;
border-radius: 4px;
resize: none;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
min-height: 36px;
max-height: 120px;
background: #fff;
transition: all 0.2s;
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
&::placeholder {
color: #bfbfbf;
}
}
.sendButton {
border-radius: 4px;
height: 36px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
background: #1890ff;
border: 1px solid #1890ff;
color: #fff;
transition: all 0.2s;
&:hover {
background: #40a9ff;
border-color: #40a9ff;
}
&:active {
background: #096dd9;
border-color: #096dd9;
}
&:disabled {
background: #f5f5f5;
border-color: #d9d9d9;
color: #bfbfbf;
cursor: not-allowed;
}
}
}
.inputHint {
padding: 4px 16px 8px;
font-size: 12px;
color: #8c8c8c;
background: #fff;
border-top: 1px solid #f0f0f0;
}
}
}
// 右侧个人资料卡片
.profileSider {
@@ -748,18 +629,7 @@
}
}
.chatFooter {
padding: 12px;
.inputContainer {
.inputArea {
.sendButton {
height: 28px;
padding: 0 12px;
}
}
}
}
.messageItem {
.messageContent {

View File

@@ -0,0 +1,181 @@
// MessageEnter 组件样式 - 微信风格
.chatFooter {
background: #f7f7f7;
border-top: 1px solid #e1e1e1;
padding: 0;
height: auto;
min-height: 100px;
}
.inputContainer {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.inputToolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: none;
}
.leftTool {
display: flex;
gap: 2px;
align-items: center;
}
.toolbarButton {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #666;
font-size: 16px;
transition: all 0.15s;
border: none;
background: transparent;
&:hover {
background: #e6e6e6;
color: #333;
}
&:active {
background: #d9d9d9;
}
}
.rightTool {
display: flex;
gap: 12px;
align-items: center;
}
.rightToolItem {
display: flex;
align-items: center;
gap: 3px;
color: #666;
font-size: 11px;
cursor: pointer;
padding: 3px 6px;
border-radius: 3px;
transition: all 0.15s;
&:hover {
background: #e6e6e6;
color: #333;
}
}
.inputArea {
display: flex;
flex-direction: column;
padding: 4px 0;
}
.inputWrapper {
border: 1px solid #d1d1d1;
border-radius: 4px;
background: #fff;
overflow: hidden;
&:focus-within {
border-color: #07c160;
}
}
.messageInput {
width: 100%;
border: none;
resize: none;
font-size: 13px;
line-height: 1.4;
padding: 8px 10px;
background: transparent;
&:focus {
box-shadow: none;
outline: none;
}
&::placeholder {
color: #b3b3b3;
}
}
.sendButtonArea {
padding: 8px 10px;
display: flex;
justify-content: flex-end;
}
.sendButton {
height: 32px;
border-radius: 4px;
font-weight: normal;
min-width: 60px;
font-size: 13px;
background: #07c160;
border-color: #07c160;
&:hover {
background: #06ad56;
border-color: #06ad56;
}
&:active {
background: #059748;
border-color: #059748;
}
&:disabled {
background: #b3b3b3;
border-color: #b3b3b3;
opacity: 1;
}
}
.inputHint {
font-size: 11px;
color: #999;
text-align: right;
margin-top: 2px;
}
// 响应式设计
@media (max-width: 768px) {
.inputContainer {
padding: 8px 12px;
}
.inputToolbar {
flex-wrap: wrap;
gap: 8px;
}
.rightTool {
gap: 8px;
}
.rightToolItem {
font-size: 11px;
padding: 2px 6px;
}
.inputArea {
flex-direction: column;
gap: 8px;
}
.sendButton {
align-self: flex-end;
min-width: 60px;
}
}

View File

@@ -0,0 +1,310 @@
import React, { useState } from "react";
import { Layout, Input, Button, Dropdown, Menu, Tooltip, Modal } from "antd";
import {
ShareAltOutlined,
SendOutlined,
SmileOutlined,
FolderOutlined,
AudioOutlined,
AudioOutlined as AudioHoldOutlined,
CodeSandboxOutlined,
MessageOutlined,
EnvironmentOutlined,
StarOutlined,
} from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import styles from "./MessageEnter.module.scss";
const { Footer } = Layout;
const { TextArea } = Input;
interface MessageEnterProps {
contract: ContractData | weChatGroup;
onSendMessage: (message: string) => void;
}
const { sendCommand } = useWebSocketStore.getState();
const MessageEnter: React.FC<MessageEnterProps> = ({
contract,
onSendMessage,
}) => {
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 || 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType: 1,
content: inputValue,
};
sendCommand("CmdSendMessage", params);
// try {
// onSendMessage(inputValue);
// setInputValue("");
// } catch (error) {
// console.error("发送失败", error);
// }
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
handleSend();
}
// Ctrl+Enter 换行由 TextArea 自动处理,不需要阻止默认行为
};
// 素材菜单项
const materialMenuItems = [
{
key: "text",
label: "文字素材",
icon: <span>📝</span>,
},
{
key: "audio",
label: "语音素材",
icon: <span>🎵</span>,
},
{
key: "image",
label: "图片素材",
icon: <span>🖼</span>,
},
{
key: "video",
label: "视频素材",
icon: <span>🎬</span>,
},
{
key: "link",
label: "链接素材",
icon: <span>🔗</span>,
},
{
key: "card",
label: "名片素材",
icon: <span>📇</span>,
},
];
const handleMaterialSelect = (key: string) => {
console.log("选择素材类型:", key);
setShowMaterialModal(true);
// 这里可以根据不同的素材类型显示不同的模态框
};
return (
<>
{/* 聊天输入 */}
<Footer className={styles.chatFooter}>
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<Tooltip title="表情">
<Button
type="text"
icon={<SmileOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="上传附件">
<Button
type="text"
icon={<FolderOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="收藏">
<Button
type="text"
icon={<StarOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="位置">
<Button
type="text"
icon={<EnvironmentOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="语音">
<Button
type="text"
icon={<AudioOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="按住说话">
<Button
type="text"
icon={<AudioHoldOutlined />}
className={styles.toolbarButton}
style={{ position: "relative" }}
>
<span
style={{
position: "absolute",
top: "2px",
right: "2px",
fontSize: "8px",
color: "#52c41a",
fontWeight: "bold",
}}
>
H
</span>
</Button>
</Tooltip>
<Dropdown
overlay={
<Menu
items={materialMenuItems}
onClick={({ key }) => handleMaterialSelect(key)}
style={{
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
}}
/>
}
trigger={["click"]}
placement="topLeft"
>
<Button
type="text"
icon={<CodeSandboxOutlined />}
className={styles.toolbarButton}
/>
</Dropdown>
</div>
<div className={styles.rightTool}>
<div className={styles.rightToolItem}>
<ShareAltOutlined />
</div>
<div className={styles.rightToolItem}>
<MessageOutlined />
</div>
</div>
</div>
<div className={styles.inputArea}>
<div className={styles.inputWrapper}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="输入消息..."
className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<div className={styles.sendButtonArea}>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim()}
className={styles.sendButton}
>
</Button>
</div>
</div>
</div>
<div className={styles.inputHint}>Ctrl+Enter换行Enter发送</div>
</div>
</Footer>
{/* 素材选择模态框 */}
<Modal
title="选择素材"
open={showMaterialModal}
onCancel={() => setShowMaterialModal(false)}
footer={[
<Button key="cancel" onClick={() => setShowMaterialModal(false)}>
</Button>,
<Button
key="confirm"
type="primary"
onClick={() => setShowMaterialModal(false)}
>
</Button>,
]}
width={800}
>
<div style={{ display: "flex", height: "400px" }}>
{/* 左侧素材分类 */}
<div
style={{
width: "200px",
background: "#f5f5f5",
borderRight: "1px solid #e8e8e8",
}}
>
<div style={{ padding: "16px", borderBottom: "1px solid #e8e8e8" }}>
<h4 style={{ margin: 0, color: "#262626" }}></h4>
</div>
<div style={{ padding: "8px 0" }}>
<div
style={{
padding: "8px 16px",
cursor: "pointer",
background: "#e6f7ff",
borderLeft: "3px solid #1890ff",
color: "#1890ff",
}}
>
4
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
...
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
D2辅助
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
ROS反馈演示...
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
...
</div>
</div>
<div style={{ padding: "16px", borderTop: "1px solid #e8e8e8" }}>
<h4 style={{ margin: 0, color: "#262626" }}></h4>
</div>
</div>
{/* 右侧内容区域 */}
<div style={{ flex: 1, padding: "16px" }}>
<div style={{ marginBottom: "16px" }}>
<Input.Search placeholder="昵称" style={{ width: "100%" }} />
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "300px",
color: "#8c8c8c",
}}
>
</div>
</div>
</div>
</Modal>
</>
);
};
export default MessageEnter;

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from "react";
import {
Layout,
Input,
Button,
Avatar,
Space,
@@ -9,34 +8,25 @@ import {
Menu,
message,
Tooltip,
Modal,
} from "antd";
import {
ShareAltOutlined,
SendOutlined,
SmileOutlined,
FolderOutlined,
PhoneOutlined,
VideoCameraOutlined,
MoreOutlined,
UserOutlined,
AudioOutlined,
AudioOutlined as AudioHoldOutlined,
DownloadOutlined,
CodeSandboxOutlined,
MessageOutlined,
FileOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
PlayCircleFilled,
EnvironmentOutlined,
TeamOutlined,
StarOutlined,
FolderOutlined,
EnvironmentOutlined,
} from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { getMessages } from "@/pages/pc/ckbox/api";
import { getChatMessages } from "@/pages/pc/ckbox/api";
import styles from "./ChatWindow.module.scss";
import {
useWebSocketStore,
@@ -44,8 +34,8 @@ import {
} from "@/store/module/websocket/websocket";
import { formatWechatTime } from "@/utils/common";
import Person from "./components/Person";
const { Header, Content, Footer } = Layout;
const { TextArea } = Input;
import MessageEnter from "./components/MessageEnter";
const { Header, Content } = Layout;
interface ChatWindowProps {
contract: ContractData | weChatGroup;
@@ -60,11 +50,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
showProfile = true,
onToggleProfile,
}) => {
const [messageApi, contextHolder] = message.useMessage();
const [, contextHolder] = message.useMessage();
const [messages, setMessages] = useState<ChatRecord[]>([]);
const [inputValue, setInputValue] = useState("");
const [loading, setLoading] = useState(false);
const [showMaterialModal, setShowMaterialModal] = useState(false);
const [pendingVideoRequests, setPendingVideoRequests] = useState<
Record<string, string>
>({});
@@ -84,7 +72,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
} else {
params.wechatChatroomId = contract.id;
}
getMessages(params)
getChatMessages(params)
.then(msg => {
setMessages(msg);
})
@@ -188,87 +176,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const handleSend = async () => {
if (!inputValue.trim()) return;
try {
const newMessage: ChatRecord = {
id: contract.id,
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract.id,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: inputValue,
msgType: 0,
msgSubType: 0,
msgSvrId: "",
isSend: false,
createTime: "",
isDeleted: false,
deleteTime: "",
sendStatus: 0,
wechatTime: 0,
origin: 0,
msgId: 0,
recalled: false,
};
setMessages(prev => [...prev, newMessage]);
onSendMessage(inputValue);
setInputValue("");
} catch (error) {
messageApi.error("发送失败");
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// 素材菜单项
const materialMenuItems = [
{
key: "text",
label: "文字素材",
icon: <span>📝</span>,
},
{
key: "audio",
label: "语音素材",
icon: <span>🎵</span>,
},
{
key: "image",
label: "图片素材",
icon: <span>🖼</span>,
},
{
key: "video",
label: "视频素材",
icon: <span>🎬</span>,
},
{
key: "link",
label: "链接素材",
icon: <span>🔗</span>,
},
{
key: "card",
label: "名片素材",
icon: <span>📇</span>,
},
];
const handleMaterialSelect = (key: string) => {
console.log("选择素材类型:", key);
setShowMaterialModal(true);
// 这里可以根据不同的素材类型显示不同的模态框
};
// 处理视频播放请求发送socket请求获取真实视频地址
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
// 生成请求ID (使用当前时间戳作为唯一标识)
@@ -867,121 +774,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div>
</Content>
{/* 聊天输入 */}
<Footer className={styles.chatFooter}>
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<Tooltip title="表情">
<Button
type="text"
icon={<SmileOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="上传附件">
<Button
type="text"
icon={<FolderOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="收藏">
<Button
type="text"
icon={<StarOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="位置">
<Button
type="text"
icon={<EnvironmentOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="语音">
<Button
type="text"
icon={<AudioOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="按住说话">
<Button
type="text"
icon={<AudioHoldOutlined />}
className={styles.toolbarButton}
style={{ position: "relative" }}
>
<span
style={{
position: "absolute",
top: "2px",
right: "2px",
fontSize: "8px",
color: "#52c41a",
fontWeight: "bold",
}}
>
H
</span>
</Button>
</Tooltip>
<Dropdown
overlay={
<Menu
items={materialMenuItems}
onClick={({ key }) => handleMaterialSelect(key)}
style={{
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
}}
/>
}
trigger={["click"]}
placement="topLeft"
>
<Button
type="text"
icon={<CodeSandboxOutlined />}
className={styles.toolbarButton}
/>
</Dropdown>
</div>
<div className={styles.rightTool}>
<div className={styles.rightToolItem}>
<ShareAltOutlined />
</div>
<div className={styles.rightToolItem}>
<MessageOutlined />
</div>
</div>
</div>
<div className={styles.inputArea}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="输入消息..."
className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim()}
className={styles.sendButton}
>
</Button>
</div>
<div className={styles.inputHint}>Ctrl+Enter换行</div>
</div>
</Footer>
{/* 消息输入组件 */}
<MessageEnter contract={contract} onSendMessage={onSendMessage} />
</Layout>
{/* 右侧个人资料卡片 */}
@@ -990,87 +784,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
showProfile={showProfile}
onToggleProfile={onToggleProfile}
/>
{/* 素材选择模态框 */}
<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>
</Layout>
);
};

View File

@@ -20,6 +20,22 @@ const messageHandlers: Record<string, MessageHandler> = {
asyncKfUserList(kfUserList);
},
// 发送消息响应
CmdSendMessageResp: message => {
console.log("发送消息响应", message);
// 在这里添加具体的处理逻辑
},
// 接收消息响应
CmdReceiveMessageResp: message => {
console.log("接收消息响应", message);
// 在这里添加具体的处理逻辑
},
//收到消息
CmdNewMessage: message => {
console.log("收到消息", message.friendMessage);
// 在这里添加具体的处理逻辑
},
// 登录响应
CmdSignInResp: message => {
console.log("登录响应", message);