FEAT => 本次更新项目为:重构上传组件的使用说明文档,统一代码风格,增加聊天文件上传组件的功能描述,并删除不再使用的样式和组件,以提升代码的可维护性和用户体验。

This commit is contained in:
超级老白兔
2025-08-11 18:06:27 +08:00
parent 4a5f9579b4
commit ea4e36d759
12 changed files with 1085 additions and 60 deletions

View File

@@ -0,0 +1,254 @@
import React, { useState } from "react";
import { Input, Button, Card, Space, Typography, Divider } from "antd";
import { SendOutlined } from "@ant-design/icons";
import ChatFileUpload from "./index";
const { TextArea } = Input;
const { Text } = Typography;
interface ChatMessage {
id: string;
type: "text" | "file";
content: string;
timestamp: Date;
fileInfo?: {
url: string;
name: string;
type: string;
size: number;
};
}
const ChatFileUploadExample: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState("");
// 处理文件上传
const handleFileUploaded = (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => {
const newMessage: ChatMessage = {
id: Date.now().toString(),
type: "file",
content: `文件: ${fileInfo.name}`,
timestamp: new Date(),
fileInfo,
};
setMessages(prev => [...prev, newMessage]);
};
// 处理文本发送
const handleSendText = () => {
if (!inputValue.trim()) return;
const newMessage: ChatMessage = {
id: Date.now().toString(),
type: "text",
content: inputValue,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
setInputValue("");
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 获取文件类型图标
const getFileTypeIcon = (type: string, name: string) => {
const lowerType = type.toLowerCase();
const lowerName = name.toLowerCase();
if (lowerType.startsWith("image/")) {
return "🖼️";
} else if (lowerType.startsWith("video/")) {
return "🎥";
} else if (lowerType.startsWith("audio/")) {
return "🎵";
} else if (lowerType === "application/pdf") {
return "📄";
} else if (lowerName.endsWith(".doc") || lowerName.endsWith(".docx")) {
return "📝";
} else if (lowerName.endsWith(".xls") || lowerName.endsWith(".xlsx")) {
return "📊";
} else if (lowerName.endsWith(".ppt") || lowerName.endsWith(".pptx")) {
return "📈";
} else {
return "📎";
}
};
return (
<div style={{ maxWidth: 600, margin: "0 auto", padding: 20 }}>
<Card title="聊天文件上传示例" style={{ marginBottom: 20 }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Text></Text>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</Space>
</Card>
{/* 聊天消息区域 */}
<Card
title="聊天记录"
style={{
height: 400,
marginBottom: 20,
overflowY: "auto",
}}
bodyStyle={{ height: 320, overflowY: "auto" }}
>
{messages.length === 0 ? (
<div style={{ textAlign: "center", color: "#999", marginTop: 100 }}>
</div>
) : (
<div>
{messages.map(message => (
<div key={message.id} style={{ marginBottom: 16 }}>
<div
style={{
background: "#f0f0f0",
padding: 12,
borderRadius: 8,
maxWidth: "80%",
wordBreak: "break-word",
}}
>
{message.type === "text" ? (
<div>{message.content}</div>
) : (
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<span>
{getFileTypeIcon(
message.fileInfo!.type,
message.fileInfo!.name,
)}
</span>
<Text strong>{message.fileInfo!.name}</Text>
</div>
<div style={{ fontSize: 12, color: "#666" }}>
: {formatFileSize(message.fileInfo!.size)}
</div>
<div style={{ fontSize: 12, color: "#666" }}>
: {message.fileInfo!.type}
</div>
<a
href={message.fileInfo!.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12, color: "#1890ff" }}
>
</a>
</div>
)}
<div
style={{
fontSize: 11,
color: "#999",
marginTop: 4,
textAlign: "right",
}}
>
{message.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* 输入区域 */}
<Card title="发送消息">
<Space direction="vertical" style={{ width: "100%" }}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入消息内容..."
autoSize={{ minRows: 2, maxRows: 4 }}
onPressEnter={e => {
if (!e.shiftKey) {
e.preventDefault();
handleSendText();
}
}}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Space>
{/* 文件上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50} // 最大50MB
accept="*/*" // 接受所有文件类型
buttonText="文件"
buttonIcon={<span>📎</span>}
/>
{/* 图片上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={10} // 最大10MB
accept="image/*" // 只接受图片
buttonText="图片"
buttonIcon={<span>🖼</span>}
/>
{/* 文档上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={20} // 最大20MB
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" // 只接受文档
buttonText="文档"
buttonIcon={<span>📄</span>}
/>
</Space>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSendText}
disabled={!inputValue.trim()}
>
</Button>
</div>
</Space>
</Card>
</div>
);
};
export default ChatFileUploadExample;

View File

@@ -0,0 +1,48 @@
.chatFileUpload {
display: inline-block;
.uploadButton {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: none;
background: transparent;
color: #666;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1890ff;
background-color: rgba(24, 144, 255, 0.1);
}
&:disabled {
color: #ccc;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
.anticon {
font-size: 16px;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.chatFileUpload {
.uploadButton {
padding: 6px 8px;
font-size: 12px;
.anticon {
font-size: 14px;
}
}
}
}

View File

@@ -0,0 +1,189 @@
import React, { useRef, useState } from "react";
import { Button, message } from "antd";
import {
PaperClipOutlined,
LoadingOutlined,
FileOutlined,
FileImageOutlined,
FileVideoOutlined,
FileAudioOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
} from "@ant-design/icons";
import { uploadFile } from "@/api/common";
import style from "./index.module.scss";
interface ChatFileUploadProps {
onFileUploaded?: (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => void;
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
accept?: string; // 接受的文件类型
buttonText?: string;
buttonIcon?: React.ReactNode;
}
const ChatFileUpload: React.FC<ChatFileUploadProps> = ({
onFileUploaded,
disabled = false,
className,
maxSize = 50, // 默认50MB
accept = "*/*", // 默认接受所有文件类型
buttonText = "发送文件",
buttonIcon = <PaperClipOutlined />,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
// 获取文件图标
const getFileIcon = (file: File) => {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
if (type.startsWith("image/")) {
return <FileImageOutlined />;
} else if (type.startsWith("video/")) {
return <FileVideoOutlined />;
} else if (type.startsWith("audio/")) {
return <FileAudioOutlined />;
} else if (type === "application/pdf") {
return <FilePdfOutlined />;
} else if (name.endsWith(".doc") || name.endsWith(".docx")) {
return <FileWordOutlined />;
} else if (name.endsWith(".xls") || name.endsWith(".xlsx")) {
return <FileExcelOutlined />;
} else if (name.endsWith(".ppt") || name.endsWith(".pptx")) {
return <FilePptOutlined />;
} else {
return <FileOutlined />;
}
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 验证文件
const validateFile = (file: File): boolean => {
// 检查文件大小
if (file.size > maxSize * 1024 * 1024) {
message.error(`文件大小不能超过 ${maxSize}MB`);
return false;
}
// 检查文件类型如果指定了accept
if (accept !== "*/*") {
const acceptTypes = accept.split(",").map(type => type.trim());
const fileType = file.type;
const fileName = file.name.toLowerCase();
const isValidType = acceptTypes.some(type => {
if (type.startsWith(".")) {
// 扩展名匹配
return fileName.endsWith(type);
} else if (type.includes("*")) {
// MIME类型通配符匹配
const baseType = type.replace("*", "");
return fileType.startsWith(baseType);
} else {
// 精确MIME类型匹配
return fileType === type;
}
});
if (!isValidType) {
message.error(`不支持的文件类型: ${file.type}`);
return false;
}
}
return true;
};
// 处理文件选择
const handleFileSelect = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// 验证文件
if (!validateFile(file)) {
// 清空input值允许重新选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setUploading(true);
try {
// 上传文件
const fileUrl = await uploadFile(file);
// 调用回调函数,传递文件信息
onFileUploaded?.({
url: fileUrl,
name: file.name,
type: file.type,
size: file.size,
});
message.success("文件上传成功");
} catch (error: any) {
message.error(error.message || "文件上传失败");
} finally {
setUploading(false);
// 清空input值允许重新选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
// 触发文件选择
const handleClick = () => {
if (disabled || uploading) return;
fileInputRef.current?.click();
};
return (
<div className={`${style.chatFileUpload} ${className || ""}`}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<Button
type="text"
icon={uploading ? <LoadingOutlined /> : buttonIcon}
onClick={handleClick}
disabled={disabled || uploading}
className={style.uploadButton}
title={buttonText}
>
{buttonText}
</Button>
</div>
);
};
export default ChatFileUpload;

View File

@@ -0,0 +1,65 @@
import React from "react";
import { Card, Space, Typography } from "antd";
import ChatFileUpload from "./index";
import ChatFileUploadExample from "./example";
const { Title, Paragraph } = Typography;
const ChatFileUploadTest: React.FC = () => {
const handleFileUploaded = (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => {
console.log("文件上传成功:", fileInfo);
alert(
`文件上传成功!\n文件名: ${fileInfo.name}\n大小: ${(fileInfo.size / 1024 / 1024).toFixed(2)}MB\n类型: ${fileInfo.type}\nURL: ${fileInfo.url}`,
);
};
return (
<div style={{ padding: 20 }}>
<Title level={2}>ChatFileUpload </Title>
<Space direction="vertical" size="large" style={{ width: "100%" }}>
{/* 基础用法 */}
<Card title="基础用法" size="small">
<Paragraph>
</Paragraph>
<Space>
<ChatFileUpload
onFileUploaded={handleFileUploaded}
buttonText="选择文件"
/>
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={10}
accept="image/*"
buttonText="选择图片"
buttonIcon={<span>🖼</span>}
/>
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={20}
accept=".pdf,.doc,.docx"
buttonText="选择文档"
buttonIcon={<span>📄</span>}
/>
</Space>
</Card>
{/* 完整聊天示例 */}
<Card title="完整聊天示例" size="small">
<Paragraph>
使ChatFileUpload组件
</Paragraph>
<ChatFileUploadExample />
</Card>
</Space>
</div>
);
};
export default ChatFileUploadTest;

View File

@@ -9,6 +9,7 @@
### 1. MainImgUpload 主图封面上传组件
#### 功能特点
- 只支持上传一张图片作为主图封面
- 上传后右上角显示删除按钮
- 支持图片预览功能
@@ -19,10 +20,10 @@
#### 使用方法
```tsx
import MainImgUpload from '@/components/Upload/MainImgUpload';
import MainImgUpload from "@/components/Upload/MainImgUpload";
const MyComponent = () => {
const [mainImage, setMainImage] = useState<string>('');
const [mainImage, setMainImage] = useState<string>("");
return (
<MainImgUpload
@@ -37,27 +38,32 @@ const MyComponent = () => {
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL
const [mainImage, setMainImage] = useState<string>('https://example.com/image.jpg');
const [mainImage, setMainImage] = useState<string>(
"https://example.com/image.jpg",
);
<MainImgUpload
value={mainImage} // 会自动显示已上传的图片
onChange={setMainImage}
/>
/>;
```
### 2. ImageUpload 多图上传组件
#### 功能特点
- 支持多张图片上传
- 可设置最大上传数量
- 支持图片预览和删除
- **支持数据回显**:编辑时自动显示已上传的图片数组
#### 使用方法
```tsx
import ImageUpload from '@/components/Upload/ImageUpload/ImageUpload';
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
@@ -74,22 +80,24 @@ const MyComponent = () => {
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL数组
const [images, setImages] = useState<string[]>([
'https://example.com/image1.jpg',
'https://example.com/image2.jpg'
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
<ImageUpload
value={images} // 会自动显示已上传的图片
onChange={setImages}
/>
/>;
```
### 3. VideoUpload 视频上传组件
#### 功能特点
- 支持视频文件上传
- 支持单个或多个视频
- 视频预览功能
@@ -97,11 +105,12 @@ const [images, setImages] = useState<string[]>([
- **支持数据回显**:编辑时自动显示已上传的视频
#### 使用方法
```tsx
import VideoUpload from '@/components/Upload/VideoUpload';
import VideoUpload from "@/components/Upload/VideoUpload";
const MyComponent = () => {
const [videoUrl, setVideoUrl] = useState<string>('');
const [videoUrl, setVideoUrl] = useState<string>("");
return (
<VideoUpload
@@ -115,67 +124,77 @@ const MyComponent = () => {
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的视频URL
const [videoUrl, setVideoUrl] = useState<string>('https://example.com/video.mp4');
const [videoUrl, setVideoUrl] = useState<string>(
"https://example.com/video.mp4",
);
<VideoUpload
value={videoUrl} // 会自动显示已上传的视频
onChange={setVideoUrl}
/>
/>;
```
### 4. FileUpload 文件上传组件
#### 功能特点
- 支持Excel、Word、PPT等文档文件
- 可配置接受的文件类型
- 文件预览和下载
- **支持数据回显**:编辑时自动显示已上传的文件
#### 使用方法
```tsx
import FileUpload from '@/components/Upload/FileUpload';
import FileUpload from "@/components/Upload/FileUpload";
const MyComponent = () => {
const [fileUrl, setFileUrl] = useState<string>('');
const [fileUrl, setFileUrl] = useState<string>("");
return (
<FileUpload
value={fileUrl}
onChange={setFileUrl}
maxSize={10} // 最大10MB
acceptTypes={['excel', 'word', 'ppt']}
acceptTypes={["excel", "word", "ppt"]}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的文件URL
const [fileUrl, setFileUrl] = useState<string>('https://example.com/document.xlsx');
const [fileUrl, setFileUrl] = useState<string>(
"https://example.com/document.xlsx",
);
<FileUpload
value={fileUrl} // 会自动显示已上传的文件
onChange={setFileUrl}
/>
/>;
```
### 5. AvatarUpload 头像上传组件
#### 功能特点
- 专门的头像上传组件
- 圆形头像显示
- 支持删除和重新上传
- **支持数据回显**:编辑时自动显示已上传的头像
#### 使用方法
```tsx
import AvatarUpload from '@/components/Upload/AvatarUpload';
import AvatarUpload from "@/components/Upload/AvatarUpload";
const MyComponent = () => {
const [avatarUrl, setAvatarUrl] = useState<string>('');
const [avatarUrl, setAvatarUrl] = useState<string>("");
return (
<AvatarUpload
@@ -188,19 +207,176 @@ const MyComponent = () => {
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的头像URL
const [avatarUrl, setAvatarUrl] = useState<string>('https://example.com/avatar.jpg');
const [avatarUrl, setAvatarUrl] = useState<string>(
"https://example.com/avatar.jpg",
);
<AvatarUpload
value={avatarUrl} // 会自动显示已上传的头像
onChange={setAvatarUrl}
/>;
```
### 6. ChatFileUpload 聊天文件上传组件
#### 功能特点
- 专门为聊天场景设计的文件上传组件
- 点击按钮直接唤醒文件选择框
- 选择文件后自动上传
- 上传成功后自动发送到聊天框
- 支持各种文件类型和大小限制
- 显示文件图标和大小信息
- 支持自定义按钮文本和图标
#### 使用方法
```tsx
import ChatFileUpload from "@/components/Upload/ChatFileUpload";
const ChatComponent = () => {
const handleFileUploaded = (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => {
// 处理上传成功的文件
console.log("文件上传成功:", fileInfo);
// 发送到聊天框
sendMessage({
type: "file",
content: fileInfo,
});
};
return (
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50} // 最大50MB
accept="*/*" // 接受所有文件类型
buttonText="发送文件"
buttonIcon={<span>📎</span>}
/>
);
};
```
#### 不同文件类型的配置示例
```tsx
// 图片上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={10}
accept="image/*"
buttonText="图片"
buttonIcon={<span>🖼️</span>}
/>
// 文档上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={20}
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
buttonText="文档"
buttonIcon={<span>📄</span>}
/>
// 视频上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={100}
accept="video/*"
buttonText="视频"
buttonIcon={<span>🎥</span>}
/>
```
#### 在聊天界面中的完整使用示例
```tsx
import React, { useState } from "react";
import { Input, Button } from "antd";
import ChatFileUpload from "@/components/Upload/ChatFileUpload";
const ChatInterface = () => {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState("");
const handleFileUploaded = fileInfo => {
const newMessage = {
id: Date.now(),
type: "file",
content: fileInfo,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
};
const handleSendText = () => {
if (!inputValue.trim()) return;
const newMessage = {
id: Date.now(),
type: "text",
content: inputValue,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
setInputValue("");
};
return (
<div>
{/* 聊天消息区域 */}
<div className="chat-messages">
{messages.map(msg => (
<div key={msg.id} className="message">
{msg.type === "file" ? (
<div>
<div>📎 {msg.content.name}</div>
<div>大小: {formatFileSize(msg.content.size)}</div>
<a href={msg.content.url} target="_blank">
查看文件
</a>
</div>
) : (
<div>{msg.content}</div>
)}
</div>
))}
</div>
{/* 输入区域 */}
<div className="chat-input">
<Input.TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入消息..."
/>
<div className="input-actions">
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50}
accept="*/*"
buttonText="文件"
/>
<Button onClick={handleSendText}>发送</Button>
</div>
</div>
</div>
);
};
```
## 数据回显机制
### 工作原理
所有Upload组件都通过以下机制实现数据回显
1. **useEffect监听value变化**当传入的value发生变化时自动更新内部状态
@@ -209,11 +385,13 @@ const [avatarUrl, setAvatarUrl] = useState<string>('https://example.com/avatar.j
4. **UI更新**:根据文件列表自动更新界面显示
### 使用场景
- **新增模式**value为空或未定义显示上传按钮
- **编辑模式**value包含已上传文件的URL自动显示文件
- **混合模式**:支持部分文件已上传,部分文件待上传
### 注意事项
1. **URL格式**确保传入的URL是有效的文件访问地址
2. **权限验证**确保文件URL在编辑时仍然可访问
3. **状态同步**value和onChange需要正确配合使用
@@ -222,6 +400,7 @@ const [avatarUrl, setAvatarUrl] = useState<string>('https://example.com/avatar.j
## 技术实现
### 核心特性
- 基于 antd Upload 组件
- 使用 antd-mobile 的 Toast 提示
- 支持 FormData 上传
@@ -230,6 +409,7 @@ const [avatarUrl, setAvatarUrl] = useState<string>('https://example.com/avatar.j
- **完整的数据回显支持**
### 文件结构
```
src/components/Upload/
├── MainImgUpload/ # 主图上传组件
@@ -237,11 +417,17 @@ src/components/Upload/
├── VideoUpload/ # 视频上传组件
├── FileUpload/ # 文件上传组件
├── AvatarUpload/ # 头像上传组件
├── ChatFileUpload/ # 聊天文件上传组件
│ ├── index.tsx # 主组件文件
│ ├── index.module.scss # 样式文件
│ └── example.tsx # 使用示例
└── README.md # 使用说明文档
```
### 统一的数据回显模式
所有组件都遵循相同的数据回显模式:
```tsx
// 1. 接收value属性
interface Props {

View File

@@ -86,7 +86,7 @@
.chatFooter {
background: #fff;
border-top: 1px solid #f0f0f0;
padding: 16px;
padding: 0;
height: auto;
min-height: auto;
flex-shrink: 0;
@@ -94,43 +94,114 @@
.inputContainer {
.inputToolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
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: #8c8c8c;
color: #666;
border: none;
padding: 4px 8px;
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: #f5f5f5;
background: #e6f7ff;
}
&:active {
background: #bae7ff;
}
}
}
.inputArea {
display: flex;
padding: 12px 16px;
gap: 8px;
align-items: flex-end;
background: #fff;
.messageInput {
flex: 1;
border-radius: 8px;
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: 8px;
height: 32px;
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;
}
}
}
@@ -238,9 +309,7 @@
.contactInfo {
.contactItem {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
color: #262626;
@@ -255,9 +324,8 @@
width: 16px;
}
span {
flex: 1;
word-break: break-all;
.contactItemText {
padding-left: 10px;
}
}
}

View File

@@ -15,11 +15,13 @@ import {
Tag,
Row,
Col,
Modal,
} from "antd";
import {
ShareAltOutlined,
SendOutlined,
SmileOutlined,
PaperClipOutlined,
FolderOutlined,
PhoneOutlined,
VideoCameraOutlined,
MoreOutlined,
@@ -29,11 +31,16 @@ import {
EnvironmentOutlined,
CalendarOutlined,
BankOutlined,
IdcardOutlined,
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 { ChatSession, MessageData, MessageType } from "../../data";
// import { getChatHistory, sendMessage } from "../api";
import styles from "./ChatWindow.module.scss";
@@ -56,6 +63,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
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(() => {
@@ -140,6 +148,46 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
};
// 素材菜单项
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 (
@@ -278,20 +326,94 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<Footer className={styles.chatFooter}>
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<Tooltip title="表情">
<Button
type="text"
icon={<SmileOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="附件">
<Button
type="text"
icon={<PaperClipOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<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
@@ -312,6 +434,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</Button>
</div>
<div className={styles.inputHint}>Ctrl+Enter换行</div>
</div>
</Footer>
</Layout>
@@ -355,23 +478,33 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<div className={styles.contactInfo}>
<div className={styles.contactItem}>
<PhoneOutlined />
<span>{contactInfo.phone}</span>
<span className={styles.contactItemText}>
{contactInfo.phone}
</span>
</div>
<div className={styles.contactItem}>
<MailOutlined />
<span>{contactInfo.email}</span>
<span className={styles.contactItemText}>
{contactInfo.email}
</span>
</div>
<div className={styles.contactItem}>
<EnvironmentOutlined />
<span>{contactInfo.location}</span>
<span className={styles.contactItemText}>
{contactInfo.location}
</span>
</div>
<div className={styles.contactItem}>
<BankOutlined />
<span>{contactInfo.company}</span>
<span className={styles.contactItemText}>
{contactInfo.company}
</span>
</div>
<div className={styles.contactItem}>
<CalendarOutlined />
<span>{contactInfo.joinDate}</span>
<span className={styles.contactItemText}>
{contactInfo.joinDate}
</span>
</div>
</div>
</Card>
@@ -409,6 +542,88 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</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>
);
};

View File

@@ -1,7 +1,7 @@
import React from "react";
import { List, Avatar, Badge } from "antd";
import { UserOutlined } from "@ant-design/icons";
import { ContactData } from "../data";
import { ContactData } from "../../data";
import styles from "./ContactList.module.scss";
interface ContactListProps {

View File

@@ -32,9 +32,9 @@ import {
} from "@ant-design/icons";
import dayjs from "dayjs";
import { ContactData, MessageData, ChatSession } from "./data";
import ChatWindow from "./components/ChatWindow";
import ContactList from "./components/ContactList";
import MessageList from "./components/MessageList";
import ChatWindow from "./components/ChatWindow/index";
import ContactList from "./components/ContactList/index";
import MessageList from "./components/MessageList/index";
import styles from "./index.module.scss";
const { Sider, Content } = Layout;