634 lines
19 KiB
TypeScript
634 lines
19 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
||
import {
|
||
Layout,
|
||
Input,
|
||
Button,
|
||
Avatar,
|
||
Space,
|
||
Dropdown,
|
||
Menu,
|
||
message,
|
||
Tooltip,
|
||
Divider,
|
||
Badge,
|
||
Card,
|
||
Tag,
|
||
Row,
|
||
Col,
|
||
Modal,
|
||
} from "antd";
|
||
import {
|
||
ShareAltOutlined,
|
||
SendOutlined,
|
||
SmileOutlined,
|
||
FolderOutlined,
|
||
PhoneOutlined,
|
||
VideoCameraOutlined,
|
||
MoreOutlined,
|
||
UserOutlined,
|
||
TeamOutlined,
|
||
MailOutlined,
|
||
EnvironmentOutlined,
|
||
CalendarOutlined,
|
||
BankOutlined,
|
||
CloseOutlined,
|
||
StarOutlined,
|
||
EnvironmentOutlined as LocationOutlined,
|
||
AudioOutlined,
|
||
AudioOutlined as AudioHoldOutlined,
|
||
CodeSandboxOutlined,
|
||
MessageOutlined,
|
||
} from "@ant-design/icons";
|
||
import dayjs from "dayjs";
|
||
import { ChatSession, MessageData, MessageType } from "../../data";
|
||
// import { getChatHistory, sendMessage } from "../api";
|
||
import styles from "./ChatWindow.module.scss";
|
||
|
||
const { Header, Content, Footer, Sider } = Layout;
|
||
const { TextArea } = Input;
|
||
|
||
interface ChatWindowProps {
|
||
chat: ChatSession;
|
||
onSendMessage: (message: string) => void;
|
||
showProfile?: boolean;
|
||
onToggleProfile?: () => void;
|
||
}
|
||
|
||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||
chat,
|
||
onSendMessage,
|
||
showProfile = true,
|
||
onToggleProfile,
|
||
}) => {
|
||
const [messages, setMessages] = useState<MessageData[]>([]);
|
||
const [inputValue, setInputValue] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
fetchChatHistory();
|
||
}, [chat.id]);
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
const fetchChatHistory = async () => {
|
||
try {
|
||
setLoading(true);
|
||
// 模拟聊天历史数据
|
||
const mockMessages: MessageData[] = [
|
||
{
|
||
id: "1",
|
||
senderId: "other",
|
||
senderName: chat.name,
|
||
content: "你好,请问有什么可以帮助您的吗?",
|
||
type: MessageType.TEXT,
|
||
timestamp: dayjs().subtract(10, "minute").toISOString(),
|
||
isRead: true,
|
||
},
|
||
{
|
||
id: "2",
|
||
senderId: "me",
|
||
senderName: "我",
|
||
content: "我想了解一下你们的产品",
|
||
type: MessageType.TEXT,
|
||
timestamp: dayjs().subtract(8, "minute").toISOString(),
|
||
isRead: true,
|
||
},
|
||
{
|
||
id: "3",
|
||
senderId: "other",
|
||
senderName: chat.name,
|
||
content: "好的,我来为您详细介绍",
|
||
type: MessageType.TEXT,
|
||
timestamp: dayjs().subtract(5, "minute").toISOString(),
|
||
isRead: true,
|
||
},
|
||
];
|
||
setMessages(mockMessages);
|
||
} catch (error) {
|
||
message.error("获取聊天记录失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
};
|
||
|
||
const handleSend = async () => {
|
||
if (!inputValue.trim()) return;
|
||
|
||
try {
|
||
const newMessage: MessageData = {
|
||
id: Date.now().toString(),
|
||
senderId: "me",
|
||
senderName: "我",
|
||
content: inputValue,
|
||
type: MessageType.TEXT,
|
||
timestamp: dayjs().toISOString(),
|
||
isRead: false,
|
||
};
|
||
|
||
setMessages(prev => [...prev, newMessage]);
|
||
onSendMessage(inputValue);
|
||
setInputValue("");
|
||
} catch (error) {
|
||
message.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);
|
||
// 这里可以根据不同的素材类型显示不同的模态框
|
||
};
|
||
|
||
const renderMessage = (msg: MessageData) => {
|
||
const isOwn = msg.senderId === "me";
|
||
return (
|
||
<div
|
||
key={msg.id}
|
||
className={`${styles.messageItem} ${
|
||
isOwn ? styles.ownMessage : styles.otherMessage
|
||
}`}
|
||
>
|
||
<div className={styles.messageContent}>
|
||
{!isOwn && (
|
||
<Avatar
|
||
size={32}
|
||
src={chat.avatar}
|
||
icon={<UserOutlined />}
|
||
className={styles.messageAvatar}
|
||
/>
|
||
)}
|
||
<div className={styles.messageBubble}>
|
||
{!isOwn && (
|
||
<div className={styles.messageSender}>{msg.senderName}</div>
|
||
)}
|
||
<div className={styles.messageText}>{msg.content}</div>
|
||
<div className={styles.messageTime}>
|
||
{dayjs(msg.timestamp).format("HH:mm")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const chatMenu = (
|
||
<Menu>
|
||
<Menu.Item key="profile" icon={<UserOutlined />}>
|
||
查看资料
|
||
</Menu.Item>
|
||
<Menu.Item key="call" icon={<PhoneOutlined />}>
|
||
语音通话
|
||
</Menu.Item>
|
||
<Menu.Item key="video" icon={<VideoCameraOutlined />}>
|
||
视频通话
|
||
</Menu.Item>
|
||
<Menu.Divider />
|
||
<Menu.Item key="pin">置顶聊天</Menu.Item>
|
||
<Menu.Item key="mute">消息免打扰</Menu.Item>
|
||
<Menu.Divider />
|
||
<Menu.Item key="clear" danger>
|
||
清空聊天记录
|
||
</Menu.Item>
|
||
</Menu>
|
||
);
|
||
|
||
// 模拟联系人详细信息
|
||
const contactInfo = {
|
||
name: chat.name,
|
||
avatar: chat.avatar,
|
||
phone: "13800138001",
|
||
email: "zhangsan@example.com",
|
||
department: "技术部",
|
||
position: "前端工程师",
|
||
company: "某某科技有限公司",
|
||
location: "北京市朝阳区",
|
||
joinDate: "2023-01-15",
|
||
status: "在线",
|
||
tags: ["技术专家", "前端", "React"],
|
||
bio: "专注于前端开发,热爱新技术,擅长React、Vue等框架。",
|
||
};
|
||
|
||
return (
|
||
<Layout className={styles.chatWindow}>
|
||
{/* 聊天主体区域 */}
|
||
<Layout className={styles.chatMain}>
|
||
{/* 聊天头部 */}
|
||
<Header className={styles.chatHeader}>
|
||
<div className={styles.chatHeaderInfo}>
|
||
<Avatar
|
||
size={40}
|
||
src={chat.avatar}
|
||
icon={chat.type === "group" ? <TeamOutlined /> : <UserOutlined />}
|
||
/>
|
||
<div
|
||
className={styles.chatHeaderDetails}
|
||
style={{
|
||
display: "flex",
|
||
}}
|
||
>
|
||
<div className={styles.chatHeaderName}>
|
||
{chat.name}
|
||
{chat.online && (
|
||
<span className={styles.chatHeaderOnlineStatus}>在线</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Space>
|
||
<Tooltip title="语音通话">
|
||
<Button
|
||
type="text"
|
||
icon={<PhoneOutlined />}
|
||
className={styles.headerButton}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="视频通话">
|
||
<Button
|
||
type="text"
|
||
icon={<VideoCameraOutlined />}
|
||
className={styles.headerButton}
|
||
/>
|
||
</Tooltip>
|
||
<Dropdown overlay={chatMenu} trigger={["click"]}>
|
||
<Button
|
||
type="text"
|
||
icon={<MoreOutlined />}
|
||
className={styles.headerButton}
|
||
/>
|
||
</Dropdown>
|
||
</Space>
|
||
</Header>
|
||
|
||
{/* 聊天内容 */}
|
||
<Content className={styles.chatContent}>
|
||
<div className={styles.messagesContainer}>
|
||
{loading ? (
|
||
<div className={styles.loadingContainer}>
|
||
<div>加载中...</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{messages.map(renderMessage)}
|
||
<div ref={messagesEndRef} />
|
||
</>
|
||
)}
|
||
</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={<LocationOutlined />}
|
||
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)}
|
||
onKeyPress={handleKeyPress}
|
||
placeholder="输入消息..."
|
||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||
className={styles.messageInput}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SendOutlined />}
|
||
onClick={handleSend}
|
||
disabled={!inputValue.trim()}
|
||
className={styles.sendButton}
|
||
>
|
||
发送
|
||
</Button>
|
||
</div>
|
||
<div className={styles.inputHint}>按下Ctrl+Enter换行</div>
|
||
</div>
|
||
</Footer>
|
||
</Layout>
|
||
|
||
{/* 右侧个人资料卡片 */}
|
||
{showProfile && (
|
||
<Sider width={280} className={styles.profileSider}>
|
||
<div className={styles.profileSiderContent}>
|
||
<div className={styles.profileHeader}>
|
||
<h3>个人资料</h3>
|
||
<Button
|
||
type="text"
|
||
icon={<CloseOutlined />}
|
||
onClick={onToggleProfile}
|
||
className={styles.closeButton}
|
||
/>
|
||
</div>
|
||
<div className={styles.profileContent}>
|
||
{/* 基本信息 */}
|
||
<Card className={styles.profileCard}>
|
||
<div className={styles.profileBasic}>
|
||
<Avatar
|
||
size={80}
|
||
src={contactInfo.avatar}
|
||
icon={<UserOutlined />}
|
||
/>
|
||
<div className={styles.profileInfo}>
|
||
<h4>{contactInfo.name}</h4>
|
||
<p className={styles.profileStatus}>
|
||
<Badge status="success" text={contactInfo.status} />
|
||
</p>
|
||
<p className={styles.profilePosition}>
|
||
{contactInfo.position} · {contactInfo.department}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 联系信息 */}
|
||
<Card title="联系信息" className={styles.profileCard}>
|
||
<div className={styles.contactInfo}>
|
||
<div className={styles.contactItem}>
|
||
<PhoneOutlined />
|
||
<span className={styles.contactItemText}>
|
||
{contactInfo.phone}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contactItem}>
|
||
<MailOutlined />
|
||
<span className={styles.contactItemText}>
|
||
{contactInfo.email}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contactItem}>
|
||
<EnvironmentOutlined />
|
||
<span className={styles.contactItemText}>
|
||
{contactInfo.location}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contactItem}>
|
||
<BankOutlined />
|
||
<span className={styles.contactItemText}>
|
||
{contactInfo.company}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contactItem}>
|
||
<CalendarOutlined />
|
||
<span className={styles.contactItemText}>
|
||
入职时间:{contactInfo.joinDate}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 标签 */}
|
||
<Card title="标签" className={styles.profileCard}>
|
||
<div className={styles.tagsContainer}>
|
||
{contactInfo.tags.map((tag, index) => (
|
||
<Tag key={index} color="blue">
|
||
{tag}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 个人简介 */}
|
||
<Card title="个人简介" className={styles.profileCard}>
|
||
<p className={styles.bioText}>{contactInfo.bio}</p>
|
||
</Card>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className={styles.profileActions}>
|
||
<Button type="primary" icon={<PhoneOutlined />} block>
|
||
语音通话
|
||
</Button>
|
||
<Button
|
||
icon={<VideoCameraOutlined />}
|
||
block
|
||
style={{ marginTop: 8 }}
|
||
>
|
||
视频通话
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Sider>
|
||
)}
|
||
|
||
{/* 素材选择模态框 */}
|
||
<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}
|
||
bodyStyle={{ padding: 0 }}
|
||
>
|
||
<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>
|
||
);
|
||
};
|
||
|
||
export default ChatWindow;
|