feat: 初始化项目基础架构与核心功能

添加项目基础文件结构、路由配置、API接口和核心组件
实现登录认证、权限控制、WebSocket通信等基础功能
引入antd-mobile UI组件库和Vite构建工具
配置TypeScript、ESLint、Prettier等开发环境
添加移动端适配方案和全局样式
完成首页、工作台、个人中心等基础页面框架
This commit is contained in:
超级老白兔
2025-09-11 15:00:57 +08:00
parent 648a61d09a
commit 9b3181576f
481 changed files with 74456 additions and 0 deletions

View File

@@ -0,0 +1,411 @@
import React, { useState, useRef, useCallback } from "react";
import { Button, message, Modal } from "antd";
import {
AudioOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
SendOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { uploadFile } from "@/api/common";
interface AudioRecorderProps {
onAudioUploaded: (audioData: { url: string; durationMs: number }) => void;
className?: string;
disabled?: boolean;
maxDuration?: number; // 最大录音时长(秒)
}
type RecordingState =
| "idle"
| "recording"
| "recorded"
| "playing"
| "uploading";
const AudioRecorder: React.FC<AudioRecorderProps> = ({
onAudioUploaded,
className,
disabled = false,
maxDuration = 60,
}) => {
const [visible, setVisible] = useState(false);
const [state, setState] = useState<RecordingState>("idle");
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string>("");
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const chunksRef = useRef<Blob[]>([]);
// 打开弹窗
const openRecorder = () => {
setVisible(true);
};
// 关闭弹窗并重置状态
const closeRecorder = () => {
if (state === "recording") {
stopRecording();
}
if (state === "playing") {
pauseAudio();
}
deleteRecording();
setVisible(false);
};
// 开始录音
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 尝试使用MP3格式如果不支持则回退到WebM
const mp3Types = [
"audio/mpeg",
"audio/mp3",
"audio/mpeg; codecs=mp3",
"audio/mp4",
"audio/mp4; codecs=mp4a.40.2",
];
let mimeType = "audio/webm"; // 默认回退格式
// 检测并选择支持的MP3格式
for (const type of mp3Types) {
if (MediaRecorder.isTypeSupported(type)) {
mimeType = type;
console.log(`使用音频格式: ${type}`);
break;
}
}
if (mimeType === "audio/webm") {
console.log("浏览器不支持MP3格式使用WebM格式");
}
const mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.ondataavailable = event => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType });
setAudioBlob(blob);
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setState("recorded");
// 停止所有音频轨道
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setState("recording");
setRecordingTime(0);
// 开始计时
timerRef.current = setInterval(() => {
setRecordingTime(prev => {
const newTime = prev + 1;
if (newTime >= maxDuration) {
stopRecording();
}
return newTime;
});
}, 1000);
} catch (error) {
console.error("录音失败:", error);
message.error("无法访问麦克风,请检查权限设置");
}
}, [maxDuration]);
// 停止录音
const stopRecording = useCallback(() => {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "recording"
) {
mediaRecorderRef.current.stop();
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// 播放录音
const playAudio = useCallback(() => {
if (audioRef.current && audioUrl) {
audioRef.current.play();
setState("playing");
}
}, [audioUrl]);
// 暂停播放
const pauseAudio = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
setState("recorded");
}
}, []);
// 删除录音
const deleteRecording = useCallback(() => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setAudioBlob(null);
setAudioUrl("");
setRecordingTime(0);
setState("idle");
}, [audioUrl]);
// 发送录音
const sendAudio = useCallback(async () => {
if (!audioBlob) return;
try {
setState("uploading");
// 创建文件对象
const timestamp = Date.now();
const fileExtension =
audioBlob.type.includes("mp3") ||
audioBlob.type.includes("mpeg") ||
audioBlob.type.includes("mp4")
? "mp3"
: "webm";
const audioFile = new File(
[audioBlob],
`audio_${timestamp}.${fileExtension}`,
{
type: audioBlob.type,
},
);
// 打印文件格式信息
console.log("音频文件信息:", {
fileName: audioFile.name,
fileType: audioFile.type,
fileSize: audioFile.size,
fileExtension: fileExtension,
blobType: audioBlob.type,
});
// 上传文件
const filePath = await uploadFile(audioFile);
// 调用回调函数传递音频URL和时长毫秒
onAudioUploaded({
url: filePath,
durationMs: recordingTime * 1000, // 将秒转换为毫秒
});
// 重置状态并关闭弹窗
deleteRecording();
setVisible(false);
message.success("语音发送成功");
} catch (error) {
console.error("语音上传失败:", error);
message.error("语音发送失败,请重试");
setState("recorded");
}
}, [audioBlob, onAudioUploaded, deleteRecording]);
// 格式化时间显示
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
// 渲染弹窗内容
const renderModalContent = () => {
switch (state) {
case "idle":
return (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div
style={{ marginBottom: "20px", fontSize: "16px", color: "#666" }}
>
</div>
<Button
type="primary"
size="large"
icon={<AudioOutlined />}
onClick={startRecording}
style={{
borderRadius: "50%",
width: "80px",
height: "80px",
fontSize: "24px",
}}
/>
</div>
);
case "recording":
return (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div style={{ marginBottom: "20px" }}>
<div
style={{
fontSize: "24px",
color: "#ff4d4f",
fontWeight: "bold",
marginBottom: "10px",
}}
>
{formatTime(recordingTime)}
</div>
<div style={{ fontSize: "14px", color: "#999" }}>
...
</div>
</div>
<Button
type="primary"
danger
size="large"
onClick={stopRecording}
style={{
borderRadius: "50%",
width: "80px",
height: "80px",
fontSize: "24px",
}}
>
</Button>
</div>
);
case "recorded":
case "playing":
return (
<div style={{ padding: "20px" }}>
<div style={{ textAlign: "center", marginBottom: "20px" }}>
<div
style={{
fontSize: "18px",
fontWeight: "bold",
marginBottom: "10px",
}}
>
: {formatTime(recordingTime)}
</div>
<div style={{ fontSize: "14px", color: "#666" }}>
{state === "playing"
? "正在播放..."
: "录音完成,可以试听或发送"}
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "center",
gap: "12px",
marginBottom: "20px",
}}
>
<Button
type="text"
size="large"
icon={
state === "playing" ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)
}
onClick={state === "playing" ? pauseAudio : playAudio}
title={state === "playing" ? "暂停播放" : "播放预览"}
/>
<Button
type="text"
size="large"
icon={<DeleteOutlined />}
onClick={deleteRecording}
title="删除重录"
danger
/>
</div>
<div style={{ textAlign: "center" }}>
<Button
type="primary"
size="large"
icon={<SendOutlined />}
onClick={sendAudio}
loading={state === ("uploading" as RecordingState)}
style={{ minWidth: "120px" }}
>
</Button>
</div>
</div>
);
case "uploading":
return (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<Button
type="primary"
loading
size="large"
style={{ minWidth: "120px" }}
>
...
</Button>
</div>
);
default:
return null;
}
};
return (
<>
<Button
type="text"
icon={<AudioOutlined />}
onClick={openRecorder}
className={className}
disabled={disabled}
title="点击录音"
/>
<Modal
title="录音"
open={visible}
onCancel={closeRecorder}
footer={null}
width={400}
centered
maskClosable={state === "idle"}
>
{renderModalContent()}
{audioUrl && (
<audio
ref={audioRef}
src={audioUrl}
onEnded={() => setState("recorded")}
style={{ display: "none" }}
/>
)}
</Modal>
</>
);
};
export default AudioRecorder;

View File

@@ -0,0 +1,484 @@
.uploadContainer {
width: 100%;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}
// 头像上传组件样式
.avatarUploadContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.avatarWrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
}
.avatarImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 40px;
}
.avatarUploadOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
.uploadLoading {
font-size: 12px;
text-align: center;
line-height: 1.4;
}
}
.avatarDeleteBtn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff4d4f;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
&:hover {
background: #ff7875;
transform: scale(1.1);
}
}
&:hover .avatarUploadOverlay {
opacity: 1;
}
}
.avatarTip {
font-size: 12px;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 200px;
}
}
// 视频上传组件样式
.videoUploadContainer {
width: 100%;
.videoUploadButton {
width: 100%;
min-height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: 32px;
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: 14px;
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: 48px;
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: 12px;
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.videoUploadContainer {
.videoUploadButton {
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
border-color: #434343;
&:hover {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-color: #40a9ff;
}
.uploadingContainer {
.uploadingText {
color: #ccc;
}
}
.uploadContent {
.uploadText {
.uploadTitle {
color: #fff;
}
.uploadSubtitle {
color: #ccc;
}
}
}
}
.videoItem {
background: #2a2a2a;
border-color: #434343;
&:hover {
border-color: #40a9ff;
}
.videoItemContent {
.videoInfo {
.videoName {
color: #fff;
}
.videoSize {
color: #ccc;
}
}
.videoActions {
.previewBtn,
.deleteBtn {
&:hover {
background: #434343;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect } from "react";
import { Toast, Dialog } from "antd-mobile";
import { UserOutlined, CameraOutlined } from "@ant-design/icons";
import style from "./index.module.scss";
interface AvatarUploadProps {
value?: string;
onChange?: (url: string) => void;
disabled?: boolean;
className?: string;
size?: number; // 头像尺寸
}
const AvatarUpload: React.FC<AvatarUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
size = 100,
}) => {
const [uploading, setUploading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState(value);
useEffect(() => {
setAvatarUrl(value);
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith("image/");
if (!isValidType) {
Toast.show("只能上传图片文件!");
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
Toast.show("图片大小不能超过5MB");
return null;
}
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
},
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("头像上传成功");
// 确保返回的是字符串URL
let url = "";
if (typeof result.data === "string") {
url = result.data;
} else if (result.data && typeof result.data === "object") {
url = result.data.url || "";
}
return { url };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("头像上传失败,请重试");
throw error;
}
};
// 处理头像上传
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || disabled || uploading) return;
const validatedFile = beforeUpload(file);
if (!validatedFile) return;
setUploading(true);
try {
const result = await upload(validatedFile);
setAvatarUrl(result.url);
onChange?.(result.url);
} catch (error) {
console.error("头像上传失败:", error);
} finally {
setUploading(false);
}
};
// 删除头像
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除头像吗?",
onConfirm: () => {
setAvatarUrl("");
onChange?.("");
Toast.show("头像已删除");
},
});
};
return (
<div className={`${style.avatarUploadContainer} ${className || ""}`}>
<div
className={style.avatarWrapper}
style={{ width: size, height: size }}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt="头像"
className={style.avatarImage}
style={{ width: size, height: size }}
/>
) : (
<div
className={style.avatarPlaceholder}
style={{ width: size, height: size }}
>
<UserOutlined />
</div>
)}
{/* 上传覆盖层 */}
<div
className={style.avatarUploadOverlay}
onClick={() =>
!disabled && !uploading && fileInputRef.current?.click()
}
>
{uploading ? (
<div className={style.uploadLoading}>...</div>
) : (
<CameraOutlined />
)}
</div>
{/* 删除按钮 */}
{avatarUrl && !disabled && (
<div className={style.avatarDeleteBtn} onClick={handleDelete}>
×
</div>
)}
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleAvatarChange}
disabled={disabled || uploading}
/>
{/* 提示文字 */}
<div className={style.avatarTip}>
{uploading
? "正在上传头像..."
: "点击头像可更换支持JPG、PNG格式大小不超过5MB"}
</div>
</div>
);
};
// 创建 ref
const fileInputRef = React.createRef<HTMLInputElement>();
export default AvatarUpload;

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,265 @@
.fileUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.fileUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.fileItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.fileItemContent {
display: flex;
align-items: center;
gap: 12px;
.fileIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
// Excel文件图标样式
:global(.anticon-file-excel) {
color: #217346;
background: rgba(33, 115, 70, 0.1);
}
// Word文件图标样式
:global(.anticon-file-word) {
color: #2b579a;
background: rgba(43, 87, 154, 0.1);
}
// PPT文件图标样式
:global(.anticon-file-ppt) {
color: #d24726;
background: rgba(210, 71, 38, 0.1);
}
// 默认文件图标样式
:global(.anticon-file) {
color: #666;
background: rgba(102, 102, 102, 0.1);
}
}
.fileInfo {
flex: 1;
min-width: 0;
.fileName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.fileActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.filePreview {
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
iframe {
border-radius: 8px;
}
}
}
// 禁用状态
.fileUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.fileUploadContainer.error {
.fileUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,459 @@
import React, { useState } from "react";
import { Upload, message, Progress, Button, Modal } from "antd";
import {
LoadingOutlined,
FileOutlined,
DeleteOutlined,
EyeOutlined,
CloudUploadOutlined,
FileExcelOutlined,
FileWordOutlined,
FilePptOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface FileUploadProps {
value?: string | string[]; // 支持单个字符串或字符串数组
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
maxCount?: number; // 最大上传数量默认为1
acceptTypes?: string[]; // 接受的文件类型
}
const FileUpload: React.FC<FileUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 10,
showPreview = true,
maxCount = 1,
acceptTypes = ["excel", "word", "ppt"],
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
// 文件类型配置
const fileTypeConfig = {
excel: {
accept: ".xlsx,.xls",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
],
icon: FileExcelOutlined,
name: "Excel文件",
extensions: ["xlsx", "xls"],
},
word: {
accept: ".docx,.doc",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
],
icon: FileWordOutlined,
name: "Word文件",
extensions: ["docx", "doc"],
},
ppt: {
accept: ".pptx,.ppt",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint",
],
icon: FilePptOutlined,
name: "PPT文件",
extensions: ["pptx", "ppt"],
},
};
// 生成accept字符串
const generateAcceptString = () => {
return acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.accept)
.filter(Boolean)
.join(",");
};
// 获取文件类型信息
const getFileTypeInfo = (file: File) => {
const extension = file.name.split(".").pop()?.toLowerCase();
for (const type of acceptTypes) {
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config && config.extensions.includes(extension || "")) {
return config;
}
}
return null;
};
// 获取文件图标
const getFileIcon = (file: File) => {
const typeInfo = getFileTypeInfo(file);
return typeInfo ? typeInfo.icon : FileOutlined;
};
React.useEffect(() => {
if (value) {
// 处理单个字符串或字符串数组
const urls = Array.isArray(value) ? value : [value];
const files: UploadFile[] = urls.map((url, index) => ({
uid: `file-${index}`,
name: `document-${index + 1}`,
status: "done",
url: url || "",
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const typeInfo = getFileTypeInfo(file);
if (!typeInfo) {
const allowedTypes = acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.name)
.filter(Boolean)
.join("、");
message.error(`只能上传${allowedTypes}`);
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`文件大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
// 模拟上传进度
const progress = Math.min(99, Math.random() * 100);
setUploadProgress(progress);
} else if (info.file.status === "done") {
setLoading(false);
setUploadProgress(100);
message.success("文件上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
if (maxCount === 1) {
// 单个文件模式
onChange?.(uploadedUrl);
} else {
// 多个文件模式
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const newUrls = [...currentUrls, uploadedUrl];
onChange?.(newUrls);
}
}
} else if (info.file.status === "error") {
setLoading(false);
setUploadProgress(0);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
if (maxCount === 1) {
onChange?.("");
} else {
// 多个文件模式,移除对应的文件
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
const removedIndex = info.fileList.findIndex(
f => f.uid === info.file.uid,
);
if (removedIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== removedIndex,
);
onChange?.(newUrls);
}
}
}
};
// 删除文件
const handleRemove = (file?: UploadFile) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个文件吗?",
okText: "确定",
cancelText: "取消",
onOk: () => {
if (maxCount === 1) {
setFileList([]);
onChange?.("");
} else if (file) {
// 多个文件模式,删除指定文件
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
if (fileIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== fileIndex,
);
onChange?.(newUrls);
}
}
message.success("文件已删除");
},
});
return true;
};
// 预览文件
const handlePreview = (url: string) => {
setPreviewUrl(url);
setPreviewVisible(true);
};
// 获取文件大小显示
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 uploadButton = (
<div className={style.fileUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
<Progress
percent={uploadProgress}
size="small"
showInfo={false}
strokeColor="#1890ff"
className={style.uploadProgress}
/>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}>
{maxCount === 1
? "上传文档"
: `上传文档 (${fileList.length}/${maxCount})`}
</div>
<div className={style.uploadSubtitle}>
{" "}
{acceptTypes
.map(
type =>
fileTypeConfig[type as keyof typeof fileTypeConfig]?.name,
)
.filter(Boolean)
.join("、")}
{maxSize}MB
{maxCount > 1 && `,最多上传 ${maxCount} 个文件`}
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
const FileIcon = file.originFileObj
? getFileIcon(file.originFileObj)
: FileOutlined;
if (file.status === "uploading") {
return (
<div className={style.fileItem}>
<div className={style.fileItemContent}>
<div className={style.fileIcon}>
<FileIcon />
</div>
<div className={style.fileInfo}>
<div className={style.fileName}>{file.name}</div>
<div className={style.fileSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.fileActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
<Progress
percent={uploadProgress}
size="small"
strokeColor="#1890ff"
className={style.itemProgress}
/>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.fileItem}>
<div className={style.fileItemContent}>
<div className={style.fileIcon}>
<FileIcon />
</div>
<div className={style.fileInfo}>
<div className={style.fileName}>{file.name}</div>
<div className={style.fileSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.fileActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.fileUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={maxCount > 1}
fileList={fileList}
accept={generateAcceptString()}
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={maxCount}
itemRender={customItemRender}
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 文件预览模态框 */}
<Modal
title="文件预览"
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
footer={null}
width={800}
centered
>
<div className={style.filePreview}>
<iframe
src={previewUrl}
style={{ width: "100%", height: "500px", border: "none" }}
title="文件预览"
/>
</div>
</Modal>
</div>
);
};
export default FileUpload;

View File

@@ -0,0 +1,141 @@
import React, { useState, useEffect } from "react";
import { ImageUploader, Toast, Dialog } from "antd-mobile";
import type { ImageUploadItem } from "antd-mobile/es/components/image-uploader";
import style from "./index.module.scss";
interface UploadComponentProps {
value?: string[];
onChange?: (urls: string[]) => void;
count?: number; // 最大上传数量
accept?: string; // 文件类型
disabled?: boolean;
className?: string;
}
const UploadComponent: React.FC<UploadComponentProps> = ({
value = [],
onChange,
count = 9,
accept = "image/*",
disabled = false,
className,
}) => {
const [fileList, setFileList] = useState<ImageUploadItem[]>([]);
// 将value转换为fileList格式
useEffect(() => {
if (value && value.length > 0) {
const files = value.map((url, index) => ({
url: url || "",
uid: `file-${index}`,
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith(accept.replace("*", ""));
if (!isValidType) {
Toast.show(`只能上传${accept}格式的文件!`);
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
Toast.show("文件大小不能超过5MB");
return null;
}
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
},
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("上传成功");
// 确保返回的是字符串URL
let url = "";
if (typeof result.data === "string") {
url = result.data;
} else if (result.data && typeof result.data === "object") {
url = result.data.url || "";
}
return { url };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("上传失败,请重试");
throw error;
}
};
// 处理文件变化
const handleChange = (files: ImageUploadItem[]) => {
setFileList(files);
// 提取URL数组并传递给父组件
const urls = files
.map(file => file.url)
.filter(url => Boolean(url)) as string[];
onChange?.(urls);
};
// 删除确认
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除这张图片吗?",
});
};
// 数量超出限制
const handleCountExceed = (exceed: number) => {
Toast.show(`最多选择 ${count} 张图片,你多选了 ${exceed}`);
};
return (
<div className={`${style.uploadContainer} ${className || ""}`}>
<ImageUploader
value={fileList}
onChange={handleChange}
upload={upload}
beforeUpload={beforeUpload}
onDelete={handleDelete}
onCountExceed={handleCountExceed}
multiple={count > 1}
maxCount={count}
showUpload={fileList.length < count && !disabled}
accept={accept}
/>
</div>
);
};
export default UploadComponent;

View File

@@ -0,0 +1,484 @@
.uploadContainer {
width: 100%;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}
// 头像上传组件样式
.avatarUploadContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.avatarWrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
}
.avatarImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 40px;
}
.avatarUploadOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
.uploadLoading {
font-size: 12px;
text-align: center;
line-height: 1.4;
}
}
.avatarDeleteBtn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff4d4f;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
&:hover {
background: #ff7875;
transform: scale(1.1);
}
}
&:hover .avatarUploadOverlay {
opacity: 1;
}
}
.avatarTip {
font-size: 12px;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 200px;
}
}
// 视频上传组件样式
.videoUploadContainer {
width: 100%;
.videoUploadButton {
width: 100%;
min-height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: 32px;
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: 14px;
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: 48px;
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: 12px;
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.videoUploadContainer {
.videoUploadButton {
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
border-color: #434343;
&:hover {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-color: #40a9ff;
}
.uploadingContainer {
.uploadingText {
color: #ccc;
}
}
.uploadContent {
.uploadText {
.uploadTitle {
color: #fff;
}
.uploadSubtitle {
color: #ccc;
}
}
}
}
.videoItem {
background: #2a2a2a;
border-color: #434343;
&:hover {
border-color: #40a9ff;
}
.videoItemContent {
.videoInfo {
.videoName {
color: #fff;
}
.videoSize {
color: #ccc;
}
}
.videoActions {
.previewBtn,
.deleteBtn {
&:hover {
background: #434343;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,291 @@
.mainImgUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.mainImgUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.mainImgItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.mainImgItemContent {
display: flex;
align-items: center;
gap: 12px;
.mainImgIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
}
.mainImgInfo {
flex: 1;
min-width: 0;
.mainImgName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mainImgSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.mainImgActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.mainImgPreview {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border-radius: 8px;
overflow: hidden;
background: #f5f5f5;
.mainImgImage {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.mainImgOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 8px;
.mainImgActions {
display: flex;
gap: 8px;
.previewBtn,
.deleteBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.9);
color: #666;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: white;
color: #1890ff;
transform: scale(1.1);
}
.anticon {
font-size: 14px;
}
}
.deleteBtn:hover {
color: #ff4d4f;
}
}
}
&:hover .mainImgOverlay {
opacity: 1;
}
}
}
}
// 禁用状态
.mainImgUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.mainImgUploadContainer.error {
.mainImgUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,367 @@
import React, { useState, useEffect } from "react";
import { Upload, message, Button } from "antd";
import {
LoadingOutlined,
PictureOutlined,
DeleteOutlined,
EyeOutlined,
CloudUploadOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface MainImgUploadProps {
value?: string;
onChange?: (url: string) => void;
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
}
const MainImgUpload: React.FC<MainImgUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 5,
showPreview = true,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
useEffect(() => {
if (value) {
const files: UploadFile[] = [
{
uid: "main-img",
name: "main-image",
status: "done",
url: value,
},
];
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith("image/");
if (!isImage) {
message.error("只能上传图片文件!");
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`图片大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
} else if (info.file.status === "done") {
setLoading(false);
message.success("图片上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
onChange?.(uploadedUrl);
}
} else if (info.file.status === "error") {
setLoading(false);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
onChange?.("");
}
};
// 删除文件
const handleRemove = () => {
setFileList([]);
onChange?.("");
message.success("图片已删除");
return true;
};
// 预览图片
const handlePreview = (url: string) => {
// 使用自定义全屏预览,确保不受父级容器限制
const modal = document.createElement("div");
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
`;
const img = document.createElement("img");
img.src = url;
img.style.cssText = `
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
`;
const closeModal = () => {
document.body.removeChild(modal);
};
modal.addEventListener("click", closeModal);
modal.appendChild(img);
document.body.appendChild(modal);
// 添加键盘事件监听
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", handleKeyDown);
}
};
document.addEventListener("keydown", handleKeyDown);
};
// 格式化文件大小
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 uploadButton = (
<div className={style.mainImgUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}></div>
<div className={style.uploadSubtitle}>
JPGPNGGIF {maxSize}MB
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
if (file.status === "uploading") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.mainImgActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.mainImgActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
<div
className={style.mainImgPreview}
onClick={e => {
// 阻止事件冒泡,防止触发删除操作
e.stopPropagation();
// 点击图片预览区域时,触发文件选择
const uploadInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
if (uploadInput) {
uploadInput.click();
}
}}
>
<img
src={file.url}
alt={file.name}
className={style.mainImgImage}
/>
<div className={style.mainImgOverlay}>
<div className={style.mainImgActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={e => {
e.stopPropagation();
handlePreview(file.url || "");
}}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={e => {
e.stopPropagation();
handleRemove();
}}
className={style.deleteBtn}
/>
</div>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.mainImgUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={false}
fileList={fileList}
accept="image/*"
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={1}
itemRender={customItemRender}
>
{fileList.length >= 1 ? null : uploadButton}
</Upload>
</div>
);
};
export default MainImgUpload;

View File

@@ -0,0 +1,451 @@
# Upload 组件使用说明
## 组件概述
本项目提供了多个专门的上传组件,所有组件都支持编辑时的数据回显功能,确保在编辑模式下能够正确显示已上传的文件。
## 组件列表
### 1. MainImgUpload 主图封面上传组件
#### 功能特点
- 只支持上传一张图片作为主图封面
- 上传后右上角显示删除按钮
- 支持图片预览功能
- 响应式设计,适配移动端
- 16:9宽高比宽度高度自适应
- **支持数据回显**:编辑时自动显示已上传的图片
#### 使用方法
```tsx
import MainImgUpload from "@/components/Upload/MainImgUpload";
const MyComponent = () => {
const [mainImage, setMainImage] = useState<string>("");
return (
<MainImgUpload
value={mainImage}
onChange={setMainImage}
maxSize={5} // 最大5MB
showPreview={true} // 显示预览按钮
disabled={false}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL
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";
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
return (
<ImageUpload
value={images}
onChange={setImages}
count={9} // 最大9张
accept="image/*"
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL数组
const [images, setImages] = useState<string[]>([
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
<ImageUpload
value={images} // 会自动显示已上传的图片
onChange={setImages}
/>;
```
### 3. VideoUpload 视频上传组件
#### 功能特点
- 支持视频文件上传
- 支持单个或多个视频
- 视频预览功能
- 文件大小验证
- **支持数据回显**:编辑时自动显示已上传的视频
#### 使用方法
```tsx
import VideoUpload from "@/components/Upload/VideoUpload";
const MyComponent = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
return (
<VideoUpload
value={videoUrl}
onChange={setVideoUrl}
maxSize={50} // 最大50MB
showPreview={true}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的视频URL
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";
const MyComponent = () => {
const [fileUrl, setFileUrl] = useState<string>("");
return (
<FileUpload
value={fileUrl}
onChange={setFileUrl}
maxSize={10} // 最大10MB
acceptTypes={["excel", "word", "ppt"]}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的文件URL
const [fileUrl, setFileUrl] = useState<string>(
"https://example.com/document.xlsx",
);
<FileUpload
value={fileUrl} // 会自动显示已上传的文件
onChange={setFileUrl}
/>;
```
### 5. AvatarUpload 头像上传组件
#### 功能特点
- 专门的头像上传组件
- 圆形头像显示
- 支持删除和重新上传
- **支持数据回显**:编辑时自动显示已上传的头像
#### 使用方法
```tsx
import AvatarUpload from "@/components/Upload/AvatarUpload";
const MyComponent = () => {
const [avatarUrl, setAvatarUrl] = useState<string>("");
return (
<AvatarUpload
value={avatarUrl}
onChange={setAvatarUrl}
size={100} // 头像尺寸
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的头像URL
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发生变化时自动更新内部状态
2. **文件列表同步**将URL转换为文件列表格式显示已上传的文件
3. **状态管理**:维护上传状态、文件列表等内部状态
4. **UI更新**:根据文件列表自动更新界面显示
### 使用场景
- **新增模式**value为空或未定义显示上传按钮
- **编辑模式**value包含已上传文件的URL自动显示文件
- **混合模式**:支持部分文件已上传,部分文件待上传
### 注意事项
1. **URL格式**确保传入的URL是有效的文件访问地址
2. **权限验证**确保文件URL在编辑时仍然可访问
3. **状态同步**value和onChange需要正确配合使用
4. **错误处理**组件会自动处理无效URL的显示
## 技术实现
### 核心特性
- 基于 antd Upload 组件
- 使用 antd-mobile 的 Toast 提示
- 支持 FormData 上传
- 自动处理文件验证和错误提示
- 集成项目统一的API请求封装
- **完整的数据回显支持**
### 文件结构
```
src/components/Upload/
├── MainImgUpload/ # 主图上传组件
├── ImageUpload/ # 多图上传组件
├── VideoUpload/ # 视频上传组件
├── FileUpload/ # 文件上传组件
├── AvatarUpload/ # 头像上传组件
├── ChatFileUpload/ # 聊天文件上传组件
│ ├── index.tsx # 主组件文件
│ ├── index.module.scss # 样式文件
│ └── example.tsx # 使用示例
└── README.md # 使用说明文档
```
### 统一的数据回显模式
所有组件都遵循相同的数据回显模式:
```tsx
// 1. 接收value属性
interface Props {
value?: string | string[];
onChange?: (url: string | string[]) => void;
}
// 2. 使用useEffect监听value变化
useEffect(() => {
if (value) {
// 将URL转换为文件列表格式
const files = convertUrlToFileList(value);
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 3. 在UI中显示文件列表
// 4. 支持编辑、删除、预览等操作
```

View File

@@ -0,0 +1,84 @@
import { uploadFile } from "@/api/common";
import React, { useRef } from "react";
import { message } from "antd";
interface SimpleFileUploadProps {
onFileUploaded?: (filePath: string) => void;
maxSize?: number; // 最大文件大小(MB)
type?: number; // 1: 图片, 2: 视频, 3: 音频, 4: 文件
slot?: React.ReactNode;
}
const SimpleFileUpload: React.FC<SimpleFileUploadProps> = ({
onFileUploaded,
maxSize = 50,
slot,
type = 4,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const accept = {
1: "image/*",
2: "video/*",
3: "audio/*",
4: "*/*",
};
// 验证文件
const validateFile = (file: File): boolean => {
if (file.size > maxSize * 1024 * 1024) {
message.error(`文件大小不能超过 ${maxSize}MB`);
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)) {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
try {
const fileUrl = await uploadFile(file);
onFileUploaded?.(fileUrl);
message.success("文件上传成功");
} catch (error: any) {
console.error("文件上传失败:", error);
message.error(error.message || "文件上传失败");
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept={accept[type]}
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<span onClick={handleClick}>{slot}</span>
</>
);
};
export default SimpleFileUpload;

View File

@@ -0,0 +1,243 @@
.videoUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.videoUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 禁用状态
.videoUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.videoUploadContainer.error {
.videoUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,381 @@
import React, { useState } from "react";
import { Upload, message, Progress, Button, Modal } from "antd";
import {
LoadingOutlined,
PlayCircleOutlined,
DeleteOutlined,
EyeOutlined,
FileOutlined,
CloudUploadOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface VideoUploadProps {
value?: string | string[]; // 支持单个字符串或字符串数组
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
maxCount?: number; // 最大上传数量默认为1
}
const VideoUpload: React.FC<VideoUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 50,
showPreview = true,
maxCount = 1,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
React.useEffect(() => {
if (value) {
// 处理单个字符串或字符串数组
const urls = Array.isArray(value) ? value : [value];
const files: UploadFile[] = urls.map((url, index) => ({
uid: `file-${index}`,
name: `video-${index + 1}`,
status: "done",
url: url || "",
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const isVideo = file.type.startsWith("video/");
if (!isVideo) {
message.error("只能上传视频文件!");
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`视频大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
// 模拟上传进度
const progress = Math.min(99, Math.random() * 100);
setUploadProgress(progress);
} else if (info.file.status === "done") {
setLoading(false);
setUploadProgress(100);
message.success("视频上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
if (maxCount === 1) {
// 单个视频模式
onChange?.(uploadedUrl);
} else {
// 多个视频模式
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const newUrls = [...currentUrls, uploadedUrl];
onChange?.(newUrls);
}
}
} else if (info.file.status === "error") {
setLoading(false);
setUploadProgress(0);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
if (maxCount === 1) {
onChange?.("");
} else {
// 多个视频模式,移除对应的视频
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
const removedIndex = info.fileList.findIndex(
f => f.uid === info.file.uid,
);
if (removedIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== removedIndex,
);
onChange?.(newUrls);
}
}
}
};
// 删除文件
const handleRemove = (file?: UploadFile) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个视频文件吗?",
okText: "确定",
cancelText: "取消",
onOk: () => {
if (maxCount === 1) {
setFileList([]);
onChange?.("");
} else if (file) {
// 多个视频模式,删除指定视频
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
if (fileIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== fileIndex,
);
onChange?.(newUrls);
}
}
message.success("视频已删除");
},
});
return true;
};
// 预览视频
const handlePreview = (url: string) => {
setPreviewUrl(url);
setPreviewVisible(true);
};
// 获取文件大小显示
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 uploadButton = (
<div className={style.videoUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
<Progress
percent={uploadProgress}
size="small"
showInfo={false}
strokeColor="#1890ff"
className={style.uploadProgress}
/>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}>
{maxCount === 1
? "上传视频"
: `上传视频 (${fileList.length}/${maxCount})`}
</div>
<div className={style.uploadSubtitle}>
MP4AVIMOV {maxSize}MB
{maxCount > 1 && `,最多上传 ${maxCount} 个视频`}
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
if (file.status === "uploading") {
return (
<div className={style.videoItem}>
<div className={style.videoItemContent}>
<div className={style.videoIcon}>
<FileOutlined />
</div>
<div className={style.videoInfo}>
<div className={style.videoName}>{file.name}</div>
<div className={style.videoSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.videoActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
<Progress
percent={uploadProgress}
size="small"
strokeColor="#1890ff"
className={style.itemProgress}
/>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.videoItem}>
<div className={style.videoItemContent}>
<div className={style.videoIcon}>
<PlayCircleOutlined />
</div>
<div className={style.videoInfo}>
<div className={style.videoName}>{file.name}</div>
<div className={style.videoSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.videoActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.videoUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={maxCount > 1}
fileList={fileList}
accept="video/*"
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={maxCount}
itemRender={customItemRender}
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 视频预览模态框 */}
<Modal
title="视频预览"
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
footer={null}
width={800}
centered
>
<div className={style.videoPreview}>
<video
controls
style={{ width: "100%", maxHeight: "400px" }}
src={previewUrl}
>
</video>
</div>
</Modal>
</div>
);
};
export default VideoUpload;