FEAT => 本次更新项目为:移除上传组件相关文件,整合上传功能至主图上传组件,优化代码结构及样式

This commit is contained in:
超级老白兔
2025-08-04 14:11:36 +08:00
parent 38f5fcf360
commit 7621b91f15
13 changed files with 1969 additions and 165 deletions

View File

@@ -11,7 +11,7 @@ import {
FilePptOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./FileUpload.module.scss";
import style from "./index.module.scss";
interface FileUploadProps {
value?: string | string[]; // 支持单个字符串或字符串数组

View File

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

View File

@@ -0,0 +1,558 @@
.mainImgUploadContainer {
width: 100%;
display: flex;
flex-direction: column;
// 覆盖 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: 180px;
min-width: 320px;
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;
flex: 1;
&: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;
}
}
.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;
}
}
}
.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: 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;
}
.mainImgInfo {
flex: 1;
min-width: 0;
.mainImgName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mainImgSize {
font-size: 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: 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);
}
}
// 响应式设计
@media (max-width: 768px) {
.mainImgUploadContainer {
.mainImgUploadButton {
aspect-ratio: 16 / 9;
min-height: 135px;
min-width: 240px;
.uploadContent {
padding: 16px;
.uploadIcon {
font-size: 36px;
}
.uploadText {
.uploadTitle {
font-size: 14px;
}
.uploadSubtitle {
font-size: 11px;
}
}
}
.uploadingContainer {
padding: 16px;
.uploadingIcon {
font-size: 28px;
}
.uploadingText {
font-size: 12px;
}
}
}
.mainImgItem {
.mainImgItemContent {
padding: 8px;
.mainImgIcon {
width: 32px;
height: 32px;
font-size: 16px;
}
.mainImgInfo {
.mainImgName {
font-size: 12px;
}
.mainImgSize {
font-size: 11px;
}
}
.mainImgActions {
.previewBtn,
.deleteBtn {
padding: 3px 6px;
font-size: 12px;
}
}
}
.mainImgPreview {
aspect-ratio: 16 / 9;
min-height: 135px;
.mainImgOverlay {
.mainImgActions {
.previewBtn,
.deleteBtn {
width: 28px;
height: 28px;
.anticon {
font-size: 12px;
}
}
}
}
}
}
}
}
@media (max-width: 480px) {
.mainImgUploadContainer {
.mainImgUploadButton {
aspect-ratio: 16 / 9;
min-height: 90px;
min-width: 160px;
.uploadContent {
padding: 12px;
gap: 8px;
.uploadIcon {
font-size: 28px;
}
.uploadText {
.uploadTitle {
font-size: 12px;
}
.uploadSubtitle {
font-size: 10px;
}
}
}
.uploadingContainer {
padding: 12px;
gap: 8px;
.uploadingIcon {
font-size: 24px;
}
.uploadingText {
font-size: 11px;
}
}
}
.mainImgItem {
.mainImgItemContent {
padding: 6px;
gap: 8px;
.mainImgIcon {
width: 28px;
height: 28px;
font-size: 14px;
}
.mainImgInfo {
.mainImgName {
font-size: 11px;
}
.mainImgSize {
font-size: 10px;
}
}
.mainImgActions {
.previewBtn,
.deleteBtn {
padding: 2px 4px;
font-size: 11px;
}
}
}
.mainImgPreview {
aspect-ratio: 16 / 9;
min-height: 90px;
.mainImgOverlay {
.mainImgActions {
.previewBtn,
.deleteBtn {
width: 24px;
height: 24px;
.anticon {
font-size: 11px;
}
}
}
}
}
}
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.mainImgUploadContainer {
.mainImgUploadButton {
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;
}
}
}
}
.mainImgItem {
background: #2a2a2a;
border-color: #434343;
&:hover {
border-color: #40a9ff;
}
.mainImgItemContent {
.mainImgInfo {
.mainImgName {
color: #fff;
}
.mainImgSize {
color: #ccc;
}
}
.mainImgActions {
.previewBtn,
.deleteBtn {
&:hover {
background: #434343;
}
}
}
}
.mainImgPreview {
background: #1f1f1f;
.mainImgOverlay {
.mainImgActions {
.previewBtn,
.deleteBtn {
background: rgba(255, 255, 255, 0.1);
color: #ccc;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,313 @@
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 img = new Image();
img.src = url;
const newWindow = window.open();
if (newWindow) {
newWindow.document.write(img.outerHTML);
}
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 自定义上传按钮
const uploadButton = (
<div className={style.mainImgUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}></div>
<div className={style.uploadSubtitle}>
JPGPNGGIF {maxSize}MB
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
if (file.status === "uploading") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.mainImgActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.mainImgActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
<div className={style.mainImgPreview}>
<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={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.mainImgUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={false}
fileList={fileList}
accept="image/*"
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={1}
itemRender={customItemRender}
>
{fileList.length >= 1 ? null : uploadButton}
</Upload>
</div>
);
};
export default MainImgUpload;

View File

@@ -1,183 +1,68 @@
# Upload 上传组件
# Upload 组件使用说明
基于 antd-mobile 的 ImageUploader 组件封装的上传组件,支持图片上传、预览、删除等功能。
## MainImgUpload 主图封面上传组件
## 功能特
### 功能特
- ✅ 支持单张/多张图片上传
- ✅ 文件类型和大小验证
- ✅ 上传进度显示
- ✅ 图片预览功能
- ✅ 删除确认
- ✅ 数量限制
- ✅ 编辑和新增状态支持
- ✅ 响应式设计
- ✅ 头像上传专用组件
- 只支持上传一张图片作为主图封面
- 上传后右上角显示删除按钮
- 支持图片预览功能
- 响应式设计,适配移动端
- 样式参考VideoUpload组件风格
## 组件列表
### 1. UploadComponent (图片上传)
通用的图片上传组件,支持多张图片上传。
### 2. AvatarUpload (头像上传)
专门的头像上传组件,支持圆形头像显示、删除功能。
### 3. VideoUpload (视频上传)
视频上传组件,支持视频文件上传和预览。
## 使用方法
### 基础用法
### 使用方法
```tsx
import React, { useState } from "react";
import UploadComponent from "@/components/Upload";
import MainImgUpload from "@/components/Upload/MainImgUpload";
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
const [mainImage, setMainImage] = useState<string>("");
return (
<UploadComponent
value={images}
onChange={setImages}
count={5}
accept="image/*"
/>
);
};
```
### 头像上传
```tsx
import React, { useState } from "react";
import AvatarUpload from "@/components/Upload/AvatarUpload";
const AvatarComponent = () => {
const [avatar, setAvatar] = useState<string>("");
return (
<AvatarUpload
value={avatar}
onChange={setAvatar}
size={100}
<MainImgUpload
value={mainImage}
onChange={setMainImage}
maxSize={5} // 最大5MB
showPreview={true} // 显示预览按钮
disabled={false}
/>
);
};
```
### 编辑模式
### Props 参数
```tsx
const EditComponent = () => {
const [images, setImages] = useState<string[]>([
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
| 参数 | 类型 | 默认值 | 说明 |
| ----------- | --------------------- | ------ | ---------------- |
| value | string | '' | 当前图片URL |
| onChange | (url: string) => void | - | 图片URL变化回调 |
| disabled | boolean | false | 是否禁用 |
| className | string | - | 自定义样式类名 |
| maxSize | number | 5 | 最大文件大小(MB) |
| showPreview | boolean | true | 是否显示预览按钮 |
return (
<UploadComponent
value={images}
onChange={setImages}
count={9}
disabled={false}
/>
);
};
### 样式特点
- 上传区域200x200px 的虚线边框区域
- 图片预览:上传后显示图片,鼠标悬停显示操作按钮
- 删除按钮:右上角红色删除图标
- 预览按钮:眼睛图标,点击在新窗口预览
- 响应式:移动端自动调整尺寸
### 文件结构
```
src/components/Upload/
├── MainImgUpload.tsx # 主图上传组件
├── mainImgUpload.module.scss # 主图上传样式
├── VideoUpload.tsx # 视频上传组件
└── index.module.scss # 通用上传样式
```
### 禁用状态
### 技术实现
```tsx
<UploadComponent value={images} onChange={setImages} disabled={true} />
```
## API
### UploadComponent Props
| 参数 | 说明 | 类型 | 默认值 |
| --------- | -------------- | -------------------------- | ----------- |
| value | 图片URL数组 | `string[]` | `[]` |
| onChange | 图片变化回调 | `(urls: string[]) => void` | - |
| count | 最大上传数量 | `number` | `9` |
| accept | 接受的文件类型 | `string` | `"image/*"` |
| disabled | 是否禁用 | `boolean` | `false` |
| className | 自定义类名 | `string` | - |
### AvatarUpload Props
| 参数 | 说明 | 类型 | 默认值 |
| --------- | ------------ | ----------------------- | ------- |
| value | 头像URL | `string` | `""` |
| onChange | 头像变化回调 | `(url: string) => void` | - |
| disabled | 是否禁用 | `boolean` | `false` |
| className | 自定义类名 | `string` | - |
| size | 头像尺寸 | `number` | `100` |
### VideoUpload Props
| 参数 | 说明 | 类型 | 默认值 |
| --------- | ------------ | ----------------------- | ------- |
| value | 视频URL | `string` | `""` |
| onChange | 视频变化回调 | `(url: string) => void` | - |
| disabled | 是否禁用 | `boolean` | `false` |
| className | 自定义类名 | `string` | - |
### 事件
| 事件名 | 说明 | 回调参数 |
| -------- | ------------------ | -------------------------- |
| onChange | 文件列表变化时触发 | `(urls: string[]) => void` |
## 注意事项
1. **文件大小限制**: 默认限制为 5MB
2. **文件类型**: 默认只接受图片文件
3. **上传接口**: 使用 `/v1/attachment/upload` 接口
4. **认证**: 自动携带 token 进行认证
5. **预览**: 点击图片可预览
6. **删除**: 删除图片会有确认提示
7. **头像组件**: 支持圆形显示、删除按钮、上传覆盖层
## 样式定制
组件支持通过 CSS 模块进行样式定制:
```scss
.uploadContainer {
// 自定义样式
:global {
.adm-image-uploader {
// 覆盖 antd-mobile 默认样式
}
}
}
.avatarUploadContainer {
// 头像上传组件样式
.avatarWrapper {
// 头像容器样式
}
}
```
## 错误处理
- 文件类型不匹配时会显示错误提示
- 文件大小超限时会显示错误提示
- 上传失败时会显示错误提示
- 网络错误时会显示错误提示
## 头像上传特性
- **圆形显示**: 头像以圆形方式显示
- **占位符**: 无头像时显示用户图标
- **上传覆盖**: 鼠标悬停显示上传图标
- **删除功能**: 右上角删除按钮
- **加载状态**: 上传时显示加载提示
- **尺寸可调**: 支持自定义头像尺寸
- 基于 antd Upload 组件
- 使用 antd-mobile 的 Toast 提示
- 支持 FormData 上传
- 自动处理文件验证和错误提示
- 集成项目统一的API请求封装

View File

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

View File

@@ -13,7 +13,7 @@ import {
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import UploadComponent from "@/components/Upload/ImageUpload";
import UploadComponent from "@/components/Upload/ImageUpload/ImageUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
import {
getContentItemDetail,

View File

@@ -2,10 +2,11 @@ import React, { useState } from "react";
import { Button, Card, Space, Divider, Toast, Switch } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import ImageUpload from "@/components/Upload/ImageUpload";
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
import AvatarUpload from "@/components/Upload/AvatarUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
import FileUpload from "@/components/Upload/FileUpload";
import MainImgUpload from "@/components/Upload/MainImgUpload";
import styles from "./upload.module.scss";
// 错误边界组件
@@ -77,6 +78,12 @@ const UploadTestPage: React.FC = () => {
"ppt",
]);
// 主图上传状态
const [mainImgUrl, setMainImgUrl] = useState<string>("");
const [mainImgDisabled, setMainImgDisabled] = useState(false);
const [mainImgMaxSize, setMainImgMaxSize] = useState(5);
const [mainImgShowPreview, setMainImgShowPreview] = useState(true);
return (
<Layout header={<NavCommon title="上传组件功能测试" />} loading={loading}>
<div className={styles.container}>
@@ -335,6 +342,79 @@ const UploadTestPage: React.FC = () => {
</div>
</Card>
</ErrorBoundary>
{/* 主图上传测试 */}
<ErrorBoundary>
<Card className={styles.testSection}>
<h3></h3>
<p></p>
{/* 主图上传控制面板 */}
<div className={styles.controlPanel}>
<div className={styles.controlItem}>
<span>:</span>
<Space>
<Button
size="small"
onClick={() =>
setMainImgMaxSize(Math.max(1, mainImgMaxSize - 1))
}
>
-
</Button>
<span>{mainImgMaxSize}MB</span>
<Button
size="small"
onClick={() =>
setMainImgMaxSize(Math.min(20, mainImgMaxSize + 1))
}
>
+
</Button>
</Space>
</div>
<div className={styles.controlItem}>
<span>:</span>
<Switch
checked={mainImgShowPreview}
onChange={setMainImgShowPreview}
/>
</div>
<div className={styles.controlItem}>
<span>:</span>
<Switch
checked={mainImgDisabled}
onChange={setMainImgDisabled}
/>
</div>
</div>
<MainImgUpload
value={mainImgUrl}
onChange={setMainImgUrl}
disabled={mainImgDisabled}
maxSize={mainImgMaxSize}
showPreview={mainImgShowPreview}
/>
<div className={styles.result}>
<h4>URL:</h4>
<div className={styles.urlList}>
<div className={styles.urlItem}>
{mainImgUrl ? (
<div className={styles.url}>
{typeof mainImgUrl === "string" ? mainImgUrl : "无效URL"}
</div>
) : (
<span className={styles.emptyText}></span>
)}
</div>
</div>
</div>
</Card>
</ErrorBoundary>
</div>
</Layout>
);