feat: 初始化项目基础架构与核心功能
添加项目基础文件结构、路由配置、API接口和核心组件 实现登录认证、权限控制、WebSocket通信等基础功能 引入antd-mobile UI组件库和Vite构建工具 配置TypeScript、ESLint、Prettier等开发环境 添加移动端适配方案和全局样式 完成首页、工作台、个人中心等基础页面框架
This commit is contained in:
411
Touchkebao/src/components/Upload/AudioRecorder/index.tsx
Normal file
411
Touchkebao/src/components/Upload/AudioRecorder/index.tsx
Normal 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;
|
||||
484
Touchkebao/src/components/Upload/AvatarUpload/index.module.scss
Normal file
484
Touchkebao/src/components/Upload/AvatarUpload/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
188
Touchkebao/src/components/Upload/AvatarUpload/index.tsx
Normal file
188
Touchkebao/src/components/Upload/AvatarUpload/index.tsx
Normal 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;
|
||||
254
Touchkebao/src/components/Upload/ChatFileUpload/example.tsx
Normal file
254
Touchkebao/src/components/Upload/ChatFileUpload/example.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
Touchkebao/src/components/Upload/ChatFileUpload/index.tsx
Normal file
189
Touchkebao/src/components/Upload/ChatFileUpload/index.tsx
Normal 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;
|
||||
265
Touchkebao/src/components/Upload/FileUpload/index.module.scss
Normal file
265
Touchkebao/src/components/Upload/FileUpload/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
459
Touchkebao/src/components/Upload/FileUpload/index.tsx
Normal file
459
Touchkebao/src/components/Upload/FileUpload/index.tsx
Normal 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;
|
||||
141
Touchkebao/src/components/Upload/ImageUpload/ImageUpload.tsx
Normal file
141
Touchkebao/src/components/Upload/ImageUpload/ImageUpload.tsx
Normal 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;
|
||||
484
Touchkebao/src/components/Upload/ImageUpload/index.module.scss
Normal file
484
Touchkebao/src/components/Upload/ImageUpload/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
291
Touchkebao/src/components/Upload/MainImgUpload/index.module.scss
Normal file
291
Touchkebao/src/components/Upload/MainImgUpload/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
367
Touchkebao/src/components/Upload/MainImgUpload/index.tsx
Normal file
367
Touchkebao/src/components/Upload/MainImgUpload/index.tsx
Normal 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}>
|
||||
支持 JPG、PNG、GIF 等格式,最大 {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;
|
||||
451
Touchkebao/src/components/Upload/README.md
Normal file
451
Touchkebao/src/components/Upload/README.md
Normal 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. 支持编辑、删除、预览等操作
|
||||
```
|
||||
84
Touchkebao/src/components/Upload/SimpleFileUpload/index.tsx
Normal file
84
Touchkebao/src/components/Upload/SimpleFileUpload/index.tsx
Normal 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;
|
||||
243
Touchkebao/src/components/Upload/VideoUpload/index.module.scss
Normal file
243
Touchkebao/src/components/Upload/VideoUpload/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
381
Touchkebao/src/components/Upload/VideoUpload/index.tsx
Normal file
381
Touchkebao/src/components/Upload/VideoUpload/index.tsx
Normal 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}>
|
||||
支持 MP4、AVI、MOV 等格式,最大 {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;
|
||||
Reference in New Issue
Block a user