feat(聊天窗口): 添加右侧联系人资料卡片组件

将联系人资料卡片从主聊天窗口组件中拆分出来,创建独立的Person组件
实现资料卡片的样式和功能,包括基本信息展示、备注编辑和操作按钮
添加响应式设计支持移动端显示
This commit is contained in:
超级老白兔
2025-08-29 15:55:03 +08:00
parent db4bc8651d
commit 02b394f31b
3 changed files with 487 additions and 217 deletions

View File

@@ -0,0 +1,204 @@
.profileSider {
background: #fff;
border-left: 1px solid #e8e8e8;
height: 100%;
overflow-y: auto;
.profileContainer {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
}
.profileHeader {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
.closeButton {
color: #8c8c8c;
&:hover {
color: #262626;
background: #f5f5f5;
}
}
}
.profileBasic {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
.profileInfo {
margin-top: 16px;
text-align: center;
width: 100%;
.profileNickname {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #262626;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profileRemark {
margin-bottom: 12px;
.remarkText {
color: #8c8c8c;
font-size: 14px;
cursor: pointer;
&:hover {
color: #1890ff;
}
}
}
.profileStatus {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #52c41a;
font-size: 14px;
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #52c41a;
}
}
}
}
.profileCard {
margin-bottom: 16px;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
:global(.ant-card-head) {
padding: 0 16px;
min-height: 40px;
border-bottom: 1px solid #f0f0f0;
:global(.ant-card-head-title) {
font-size: 14px;
font-weight: 500;
color: #262626;
}
}
:global(.ant-card-body) {
padding: 16px;
}
.infoItem {
display: flex;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.infoIcon {
color: #8c8c8c;
margin-right: 8px;
width: 16px;
flex-shrink: 0;
}
.infoLabel {
color: #8c8c8c;
font-size: 14px;
width: 60px;
flex-shrink: 0;
}
.infoValue {
color: #262626;
font-size: 14px;
flex: 1;
word-break: break-all;
}
}
.tagsContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
:global(.ant-tag) {
margin: 0;
border-radius: 4px;
}
}
.bioText {
margin: 0;
color: #595959;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
}
.profileActions {
margin-top: auto;
padding-top: 16px;
:global(.ant-btn) {
border-radius: 6px;
font-weight: 500;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.profileSider {
width: 280px !important;
.profileContainer {
padding: 12px;
}
.profileBasic {
.profileInfo {
.profileNickname {
font-size: 16px;
}
}
}
.profileCard {
:global(.ant-card-body) {
padding: 12px;
}
.infoItem {
margin-bottom: 10px;
.infoLabel {
width: 50px;
font-size: 13px;
}
.infoValue {
font-size: 13px;
}
}
}
}
}

View File

@@ -0,0 +1,272 @@
import React, { useState, useEffect } from "react";
import {
Layout,
Input,
Button,
Avatar,
Tooltip,
Card,
Tag,
message,
} from "antd";
import {
PhoneOutlined,
VideoCameraOutlined,
UserOutlined,
TeamOutlined,
MailOutlined,
EnvironmentOutlined,
CalendarOutlined,
BankOutlined,
CloseOutlined,
StarOutlined,
EditOutlined,
CheckOutlined,
} from "@ant-design/icons";
import { ContractData } from "@/pages/pc/ckbox/data";
import { useCkChatStore } from "@/store/module/ckchat";
import styles from "./Person.module.scss";
const { Sider } = Layout;
interface PersonProps {
contract: ContractData;
showProfile: boolean;
onToggleProfile?: () => void;
}
const Person: React.FC<PersonProps> = ({
contract,
showProfile,
onToggleProfile,
}) => {
const [messageApi, contextHolder] = message.useMessage();
const [isEditingRemark, setIsEditingRemark] = useState(false);
const [remarkValue, setRemarkValue] = useState(contract.conRemark || "");
const kfSelectedUser = useCkChatStore(state => state.kfSelectedUser());
// 当contract变化时更新备注值
useEffect(() => {
setRemarkValue(contract.conRemark || "");
setIsEditingRemark(false);
}, [contract.conRemark]);
// 处理备注保存
const handleSaveRemark = () => {
// 这里应该调用API保存备注到后端
// 暂时只更新本地状态
messageApi.success("备注保存成功");
setIsEditingRemark(false);
// 更新contract对象中的备注实际项目中应该通过props回调或状态管理
};
// 处理取消编辑
const handleCancelEdit = () => {
setRemarkValue(contract.conRemark || "");
setIsEditingRemark(false);
};
// 模拟联系人详细信息
const contractInfo = {
name: contract.name,
nickname: contract.nickname,
conRemark: remarkValue, // 使用当前编辑的备注值
alias: contract.alias,
wechatId: contract.wechatId,
avatar: contract.avatar,
phone: contract.phone || "-",
email: contract.email || "-",
department: contract.department || "-",
position: contract.position || "-",
company: contract.company || "-",
location: contract.location || "-",
joinDate: contract.joinDate || "-",
status: "在线",
tags: contract.labels,
bio: contract.bio || "-",
};
if (!showProfile) {
return null;
}
return (
<>
{contextHolder}
<Sider width={320} className={styles.profileSider}>
<div className={styles.profileContainer}>
{/* 关闭按钮 */}
<div className={styles.profileHeader}>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onToggleProfile}
className={styles.closeButton}
/>
</div>
{/* 头像和基本信息 */}
<div className={styles.profileBasic}>
<Avatar
size={80}
src={contractInfo.avatar}
icon={<UserOutlined />}
/>
<div className={styles.profileInfo}>
<Tooltip
title={contractInfo.nickname || contractInfo.name}
placement="top"
>
<h4 className={styles.profileNickname}>
{contractInfo.nickname || contractInfo.name}
</h4>
</Tooltip>
<div className={styles.profileRemark}>
{JSON.stringify(kfSelectedUser)}
{isEditingRemark ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Input
value={remarkValue}
onChange={e => setRemarkValue(e.target.value)}
placeholder="请输入备注"
size="small"
style={{ flex: 1 }}
/>
<Button
type="text"
size="small"
icon={<CheckOutlined />}
onClick={handleSaveRemark}
style={{ color: "#52c41a" }}
/>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={handleCancelEdit}
style={{ color: "#ff4d4f" }}
/>
</div>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span className={styles.remarkText}>
{contractInfo.conRemark || "点击添加备注"}
</span>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => setIsEditingRemark(true)}
/>
</div>
)}
</div>
<div className={styles.profileStatus}>
<span className={styles.statusDot}></span>
{contractInfo.status}
</div>
</div>
</div>
{/* 详细信息卡片 */}
<Card title="详细信息" className={styles.profileCard}>
<div className={styles.infoItem}>
<TeamOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.wechatId}</span>
</div>
<div className={styles.infoItem}>
<UserOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.alias}</span>
</div>
<div className={styles.infoItem}>
<PhoneOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.phone}</span>
</div>
<div className={styles.infoItem}>
<MailOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.email}</span>
</div>
<div className={styles.infoItem}>
<BankOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>
{contractInfo.department}
</span>
</div>
<div className={styles.infoItem}>
<StarOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.position}</span>
</div>
<div className={styles.infoItem}>
<BankOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.company}</span>
</div>
<div className={styles.infoItem}>
<EnvironmentOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.location}</span>
</div>
<div className={styles.infoItem}>
<CalendarOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.joinDate}</span>
</div>
</Card>
{/* 标签 */}
<Card title="标签" className={styles.profileCard}>
<div className={styles.tagsContainer}>
{contractInfo.tags?.map((tag, index) => (
<Tag key={index} color="blue">
{tag}
</Tag>
))}
</div>
</Card>
{/* 个人简介 */}
<Card title="个人简介" className={styles.profileCard}>
<p className={styles.bioText}>{contractInfo.bio}</p>
</Card>
{/* 操作按钮 */}
<div className={styles.profileActions}>
<Button type="primary" icon={<PhoneOutlined />} block>
</Button>
<Button
icon={<VideoCameraOutlined />}
block
style={{ marginTop: 8 }}
>
</Button>
</div>
</div>
</Sider>
</>
);
};
export default Person;

View File

@@ -9,9 +9,6 @@ import {
Menu,
message,
Tooltip,
Badge,
Card,
Tag,
Modal,
} from "antd";
import {
@@ -23,14 +20,6 @@ import {
VideoCameraOutlined,
MoreOutlined,
UserOutlined,
TeamOutlined,
MailOutlined,
EnvironmentOutlined,
CalendarOutlined,
BankOutlined,
CloseOutlined,
StarOutlined,
EnvironmentOutlined as LocationOutlined,
AudioOutlined,
AudioOutlined as AudioHoldOutlined,
DownloadOutlined,
@@ -42,8 +31,9 @@ import {
FileExcelOutlined,
FilePptOutlined,
PlayCircleFilled,
EditOutlined,
CheckOutlined,
EnvironmentOutlined,
TeamOutlined,
StarOutlined,
} from "@ant-design/icons";
import { ChatRecord, ContractData } from "@/pages/pc/ckbox/data";
import { clearUnreadCount, getMessages } from "@/pages/pc/ckbox/api";
@@ -51,7 +41,8 @@ import styles from "./ChatWindow.module.scss";
import { useWebSocketStore, WebSocketMessage } from "@/store/module/websocket";
import { formatWechatTime } from "@/utils/common";
import { useCkChatStore } from "@/store/module/ckchat";
const { Header, Content, Footer, Sider } = Layout;
import Person from "./components/Person";
const { Header, Content, Footer } = Layout;
const { TextArea } = Input;
interface ChatWindowProps {
@@ -75,8 +66,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const [pendingVideoRequests, setPendingVideoRequests] = useState<
Record<string, string>
>({});
const [isEditingRemark, setIsEditingRemark] = useState(false);
const [remarkValue, setRemarkValue] = useState(contract.conRemark || "");
const messagesEndRef = useRef<HTMLDivElement>(null);
const kfSelectedUser = useCkChatStore(state => state.kfSelectedUser());
@@ -100,12 +89,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
});
}, [contract.id]);
// 当contract变化时更新备注值
useEffect(() => {
setRemarkValue(contract.conRemark || "");
setIsEditingRemark(false);
}, [contract.conRemark]);
useEffect(() => {
// 只有在非视频加载操作时才自动滚动到底部
// 检查是否有视频正在加载中
@@ -792,21 +775,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
);
};
// 处理备注保存
const handleSaveRemark = () => {
// 这里应该调用API保存备注到后端
// 暂时只更新本地状态
messageApi.success("备注保存成功");
setIsEditingRemark(false);
// 更新contract对象中的备注实际项目中应该通过props回调或状态管理
};
// 处理取消编辑
const handleCancelEdit = () => {
setRemarkValue(contract.conRemark || "");
setIsEditingRemark(false);
};
const chatMenu = (
<Menu>
<Menu.Item key="profile" icon={<UserOutlined />}>
@@ -828,26 +796,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</Menu>
);
// 模拟联系人详细信息
const contractInfo = {
name: contract.name,
nickname: contract.nickname,
conRemark: remarkValue, // 使用当前编辑的备注值
alias: contract.alias,
wechatId: contract.wechatId,
avatar: contract.avatar,
phone: contract.phone || "-",
email: contract.email || "-",
department: contract.department || "-",
position: contract.position || "-",
company: contract.company || "-",
location: contract.location || "-",
joinDate: contract.joinDate || "-",
status: "在线",
tags: contract.labels,
bio: contract.bio || "-",
};
return (
<Layout className={styles.chatWindow}>
{contextHolder}
@@ -944,7 +892,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<Tooltip title="位置">
<Button
type="text"
icon={<LocationOutlined />}
icon={<EnvironmentOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
@@ -1033,165 +981,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</Layout>
{/* 右侧个人资料卡片 */}
{showProfile && (
<Sider width={330} 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={contractInfo.avatar}
icon={<UserOutlined />}
/>
<div className={styles.profileInfo}>
<Tooltip
title={contractInfo.nickname || contractInfo.name}
placement="top"
>
<h4 className={styles.profileNickname}>
{contractInfo.nickname || contractInfo.name}
</h4>
</Tooltip>
<div className={styles.profileRemark}>
{JSON.stringify(kfSelectedUser)}
{isEditingRemark ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Input
value={remarkValue}
onChange={e => setRemarkValue(e.target.value)}
placeholder="请输入备注"
size="small"
style={{ flex: 1 }}
/>
<Button
type="text"
size="small"
icon={<CheckOutlined />}
onClick={handleSaveRemark}
style={{ color: "#52c41a" }}
/>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={handleCancelEdit}
style={{ color: "#ff4d4f" }}
/>
</div>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>: {contractInfo.conRemark || "无"}</span>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => setIsEditingRemark(true)}
style={{ color: "#1890ff" }}
/>
</div>
)}
</div>
<p className={styles.profileWechatId}>
: {contractInfo.alias || contractInfo.wechatId}
</p>
</div>
</div>
</Card>
{/* 联系信息 */}
<Card title="联系信息" className={styles.profileCard}>
<div className={styles.contractInfo}>
<div className={styles.contractItem}>
<PhoneOutlined />
<span className={styles.contractItemText}>
{contractInfo.phone}
</span>
</div>
<div className={styles.contractItem}>
<MailOutlined />
<span className={styles.contractItemText}>
{contractInfo.email}
</span>
</div>
<div className={styles.contractItem}>
<EnvironmentOutlined />
<span className={styles.contractItemText}>
{contractInfo.location}
</span>
</div>
<div className={styles.contractItem}>
<BankOutlined />
<span className={styles.contractItemText}>
{contractInfo.company}
</span>
</div>
<div className={styles.contractItem}>
<CalendarOutlined />
<span className={styles.contractItemText}>
{contractInfo.joinDate}
</span>
</div>
</div>
</Card>
{/* 标签 */}
<Card title="标签" className={styles.profileCard}>
<div className={styles.tagsContainer}>
{contractInfo.tags.map((tag, index) => (
<Tag key={index} color="blue">
{tag}
</Tag>
))}
</div>
</Card>
{/* 个人简介 */}
<Card title="个人简介" className={styles.profileCard}>
<p className={styles.bioText}>{contractInfo.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>
)}
<Person
contract={contract}
showProfile={showProfile}
onToggleProfile={onToggleProfile}
/>
{/* 素材选择模态框 */}
<Modal