Merge branch 'yongpxu-dev' into yongpxu-dev2

This commit is contained in:
超级老白兔
2025-08-04 17:12:14 +08:00
27 changed files with 4239 additions and 785 deletions

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

@@ -69,7 +69,14 @@ const AvatarUpload: React.FC<AvatarUploadProps> = ({
if (result.code === 200) {
Toast.show("头像上传成功");
return { url: result.data.url || result.data };
// 确保返回的是字符串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 || "上传失败");
}

View File

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

View File

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

View File

@@ -79,7 +79,14 @@ const UploadComponent: React.FC<UploadComponentProps> = ({
if (result.code === 200) {
Toast.show("上传成功");
return { url: result.data.url || result.data };
// 确保返回的是字符串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 || "上传失败");
}

View File

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

View File

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

View File

@@ -0,0 +1,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,265 @@
# Upload 上传组件
# Upload 组件使用说明
基于 antd-mobile 的 ImageUploader 组件封装的上传组件,支持图片上传、预览、删除等功能。
## 组件概述
## 功能特性
- ✅ 支持单张/多张图片上传
- ✅ 文件类型和大小验证
- ✅ 上传进度显示
- ✅ 图片预览功能
- ✅ 删除确认
- ✅ 数量限制
- ✅ 编辑和新增状态支持
- ✅ 响应式设计
- ✅ 头像上传专用组件
本项目提供了多个专门的上传组件,所有组件都支持编辑时的数据回显功能,确保在编辑模式下能够正确显示已上传的文件。
## 组件列表
### 1. UploadComponent (图片上传)
### 1. MainImgUpload 主图封面上传组件
通用的图片上传组件,支持多张图片上传。
#### 功能特点
- 只支持上传一张图片作为主图封面
- 上传后右上角显示删除按钮
- 支持图片预览功能
- 响应式设计,适配移动端
- 16:9宽高比宽度高度自适应
- **支持数据回显**:编辑时自动显示已上传的图片
### 2. AvatarUpload (头像上传)
专门的头像上传组件,支持圆形头像显示、删除功能。
### 3. VideoUpload (视频上传)
视频上传组件,支持视频文件上传和预览。
## 使用方法
### 基础用法
#### 使用方法
```tsx
import React, { useState } from "react";
import UploadComponent from "@/components/Upload";
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 (
<UploadComponent
<ImageUpload
value={images}
onChange={setImages}
count={5}
count={9} // 最大9张
accept="image/*"
/>
);
};
```
### 头像上传
#### 编辑模式数据回显
```tsx
import React, { useState } from "react";
import AvatarUpload from "@/components/Upload/AvatarUpload";
// 编辑模式下传入已有的图片URL数组
const [images, setImages] = useState<string[]>([
'https://example.com/image1.jpg',
'https://example.com/image2.jpg'
]);
const AvatarComponent = () => {
const [avatar, setAvatar] = useState<string>("");
<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={avatar}
onChange={setAvatar}
size={100}
disabled={false}
value={avatarUrl}
onChange={setAvatarUrl}
size={100} // 头像尺寸
/>
);
};
```
### 编辑模式
#### 编辑模式数据回显
```tsx
const EditComponent = () => {
const [images, setImages] = useState<string[]>([
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
// 编辑模式下传入已有的头像URL
const [avatarUrl, setAvatarUrl] = useState<string>('https://example.com/avatar.jpg');
return (
<UploadComponent
value={images}
onChange={setImages}
count={9}
disabled={false}
/>
);
};
<AvatarUpload
value={avatarUrl} // 会自动显示已上传的头像
onChange={setAvatarUrl}
/>
```
### 禁用状态
## 数据回显机制
```tsx
<UploadComponent value={images} onChange={setImages} disabled={true} />
### 工作原理
所有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/ # 头像上传组件
└── README.md # 使用说明文档
```
## 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 默认样式
}
}
### 统一的数据回显模式
所有组件都遵循相同的数据回显模式:
```tsx
// 1. 接收value属性
interface Props {
value?: string | string[];
onChange?: (url: string | string[]) => void;
}
.avatarUploadContainer {
// 头像上传组件样式
.avatarWrapper {
// 头像容器样
// 2. 使用useEffect监听value变化
useEffect(() => {
if (value) {
// 将URL转换为文件列表格
const files = convertUrlToFileList(value);
setFileList(files);
} else {
setFileList([]);
}
}
}, [value]);
// 3. 在UI中显示文件列表
// 4. 支持编辑、删除、预览等操作
```
## 错误处理
- 文件类型不匹配时会显示错误提示
- 文件大小超限时会显示错误提示
- 上传失败时会显示错误提示
- 网络错误时会显示错误提示
## 头像上传特性
- **圆形显示**: 头像以圆形方式显示
- **占位符**: 无头像时显示用户图标
- **上传覆盖**: 鼠标悬停显示上传图标
- **删除功能**: 右上角删除按钮
- **加载状态**: 上传时显示加载提示
- **尺寸可调**: 支持自定义头像尺寸

View File

@@ -1,145 +0,0 @@
import React, { useState } from "react";
import { Upload, message } from "antd";
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface VideoUploadProps {
value?: string;
onChange?: (url: string) => void;
disabled?: boolean;
className?: string;
}
const VideoUpload: React.FC<VideoUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
React.useEffect(() => {
if (value) {
const file: UploadFile = {
uid: "-1",
name: "video",
status: "done",
url: value || "", // 确保 URL 不为 undefined
};
setFileList([file]);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const isVideo = file.type.startsWith("video/");
if (!isVideo) {
message.error("只能上传视频文件!");
return false;
}
const isLt50M = file.size / 1024 / 1024 < 50;
if (!isLt50M) {
message.error("视频大小不能超过50MB");
return false;
}
return true; // 允许上传
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
console.log("VideoUpload handleChange info:", info);
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => ({
...file,
url:
file.url ||
file.response?.data ||
file.response?.url ||
file.response ||
"",
}));
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
} else if (info.file.status === "done") {
setLoading(false);
message.success("上传成功");
// 从响应中获取上传后的URL
const uploadedUrl =
info.file.response?.data ||
info.file.response?.url ||
info.file.response ||
"";
if (uploadedUrl) {
// 调用onChange
onChange?.(uploadedUrl);
}
} else if (info.file.status === "error") {
setLoading(false);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
// 文件被删除
onChange?.("");
}
};
// 删除文件
const handleRemove = () => {
setFileList([]);
onChange?.("");
return true;
};
const uploadButton = (
<div className={style["upload-button"]}>
{loading ? (
<div className={style["uploading"]}>
<LoadingOutlined className={style["upload-icon"]} />
<div className={style["upload-text"]}>...</div>
</div>
) : (
<>
<PlusOutlined className={style["upload-icon"]} />
<div className={style["upload-text"]}></div>
</>
)}
</div>
);
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style["upload-container"]} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={false}
fileList={fileList}
accept="video/*"
listType="text"
showUploadList={true}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={1}
>
{fileList.length >= 1 ? null : uploadButton}
</Upload>
</div>
);
};
export default VideoUpload;

View File

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

View File

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

View File

@@ -1,210 +0,0 @@
.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;
}
}

View File

@@ -108,18 +108,7 @@ const Login: React.FC = () => {
// 根据设备数量判断跳转
if (deviceTotal > 0) {
// 有设备跳转到首页或重定向URL
const returnUrl = searchParams.get("returnUrl");
if (returnUrl) {
const decodedUrl = decodeURIComponent(returnUrl);
if (isLoginPage(decodedUrl)) {
navigate("/");
} else {
window.location.href = decodedUrl;
}
} else {
navigate("/");
}
navigate("/");
} else {
// 没有设备,跳转到引导页面
navigate("/guide");

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";
import UploadComponent from "@/components/Upload/ImageUpload/ImageUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
import {
getContentItemDetail,

View File

@@ -16,7 +16,7 @@ export function updatePlan(planId: string, data: any) {
// 获取计划详情
export function getPlanDetail(planId: string) {
return request(`/v1/scenarios/plans/${planId}`, undefined, "GET");
return request(`/v1/plan/detail?planId=${planId}`, undefined, "GET");
}
// PlanDetail 类型定义(可根据实际接口返回结构补充字段)

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { message } from "antd";
import { message, Button, Space } from "antd";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
import NavCommon from "@/components/NavCommon";
import BasicSettings from "./steps/BasicSettings";
import FriendRequestSettings from "./steps/FriendRequestSettings";
@@ -174,34 +175,42 @@ export default function NewPlan() {
isEdit={isEdit}
formData={formData}
onChange={onChange}
onNext={handleNext}
sceneList={sceneList}
sceneLoading={sceneLoading}
/>
);
case 2:
return (
<FriendRequestSettings
formData={formData}
onChange={onChange}
onNext={handleNext}
onPrev={handlePrev}
/>
<FriendRequestSettings formData={formData} onChange={onChange} />
);
case 3:
return (
<MessageSettings
formData={formData}
onChange={onChange}
onNext={handleSave}
onPrev={handlePrev}
/>
);
return <MessageSettings formData={formData} onChange={onChange} />;
default:
return null;
}
};
// 渲染底部按钮
const renderFooterButtons = () => {
return (
<div style={{ padding: "16px", display: "flex", gap: "12px" }}>
{currentStep > 1 && (
<Button onClick={handlePrev} size="large" style={{ flex: 1 }}>
</Button>
)}
<Button
type="primary"
size="large"
onClick={handleNext}
style={{ flex: 1 }}
>
{currentStep === steps.length ? "完成" : "下一步"}
</Button>
</div>
);
};
return (
<Layout
header={
@@ -210,6 +219,7 @@ export default function NewPlan() {
<StepIndicator currentStep={currentStep} steps={steps} />
</>
}
footer={renderFooterButtons()}
>
{renderStepContent()}
</Layout>

View File

@@ -16,7 +16,6 @@ interface BasicSettingsProps {
isEdit: boolean;
formData: any;
onChange: (data: any) => void;
onNext?: () => void;
sceneList: any[];
sceneLoading: boolean;
}
@@ -94,7 +93,6 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
isEdit,
formData,
onChange,
onNext,
sceneList,
sceneLoading,
}) => {
@@ -759,12 +757,6 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
onChange={value => onChange({ ...formData, enabled: value })}
/>
</div>
<div className={styles["basic-footer-switch"]}>
<ButtonMobile block color="primary" onClick={onNext}>
</ButtonMobile>
</div>
</div>
);
};

View File

@@ -9,8 +9,6 @@ import styles from "./friend.module.scss";
interface FriendRequestSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
}
// 招呼语模板
@@ -32,8 +30,6 @@ const remarkTypes = [
const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
formData,
onChange,
onNext,
onPrev,
}) => {
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
const [hasWarnings, setHasWarnings] = useState(false);
@@ -82,11 +78,6 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
setIsTemplateDialogOpen(false);
};
const handleNext = () => {
// 即使有警告也允许进入下一步,但会显示提示
onNext();
};
return (
<div className={styles["friend-container"]}>
{/* 选择设备区块 */}
@@ -211,14 +202,6 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
/>
)}
{/* 底部按钮 */}
<div className={styles["friend-footer"]}>
<Button onClick={onPrev}></Button>
<Button type="primary" onClick={handleNext}>
</Button>
</div>
{/* 招呼语模板弹窗 */}
<Modal
open={isTemplateDialogOpen}

View File

@@ -1,9 +1,8 @@
import React, { useState, useRef } from "react";
import { Input, Button, Tabs, Modal, Alert, message } from "antd";
import React, { useState } from "react";
import { Input, Button, Tabs, Modal, message } from "antd";
import {
PlusOutlined,
CloseOutlined,
UploadOutlined,
ClockCircleOutlined,
MessageOutlined,
PictureOutlined,
@@ -14,7 +13,13 @@ import {
TeamOutlined,
} from "@ant-design/icons";
import styles from "./messages.module.scss";
import { uploadFile } from "@/api/common";
// 导入Upload组件
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
import FileUpload from "@/components/Upload/FileUpload";
import MainImgUpload from "@/components/Upload/MainImgUpload";
// 导入GroupSelection组件
import GroupSelection from "@/components/GroupSelection";
interface MessageContent {
id: string;
@@ -31,7 +36,7 @@ interface MessageContent {
description?: string;
address?: string;
coverImage?: string;
groupId?: string;
groupIds?: string[]; // 改为数组以支持GroupSelection组件
linkUrl?: string;
}
@@ -43,8 +48,6 @@ interface DayPlan {
interface MessageSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
}
// 消息类型配置
@@ -58,18 +61,9 @@ const messageTypes = [
{ id: "group", icon: TeamOutlined, label: "邀请入群" },
];
// 模拟群组数据
const mockGroups = [
{ id: "1", name: "产品交流群1", memberCount: 156 },
{ id: "2", name: "产品交流群2", memberCount: 234 },
{ id: "3", name: "产品交流群3", memberCount: 89 },
];
const MessageSettings: React.FC<MessageSettingsProps> = ({
formData,
onChange,
onNext,
onPrev,
}) => {
const [dayPlans, setDayPlans] = useState<DayPlan[]>([
{
@@ -86,15 +80,6 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
},
]);
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false);
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState("");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploadingIndex, setUploadingIndex] = useState<string | null>(null);
const [uploadingType, setUploadingType] = useState<
"miniprogram" | "link" | null
>(null);
const [uploadingDay, setUploadingDay] = useState<number | null>(null);
const [uploadingMsgIdx, setUploadingMsgIdx] = useState<number | null>(null);
// 添加新消息
const handleAddMessage = (dayIndex: number, type = "text") => {
@@ -176,64 +161,61 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
message.success(`已添加第${newDay}天的消息计划`);
};
// 选择群组
const handleSelectGroup = (groupId: string) => {
setSelectedGroupId(groupId);
setIsGroupSelectOpen(false);
message.success(
`已选择群组:${mockGroups.find(g => g.id === groupId)?.name}`,
);
};
// 触发文件选择
const triggerUpload = (
dayIdx: number,
msgIdx: number,
type: "miniprogram" | "link",
) => {
setUploadingDay(dayIdx);
setUploadingMsgIdx(msgIdx);
setUploadingType(type);
setTimeout(() => {
fileInputRef.current?.click();
}, 0);
};
// 处理文件上传
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (
!file ||
uploadingDay === null ||
uploadingMsgIdx === null ||
!uploadingType
)
// 删除天数计划
const handleRemoveDayPlan = (dayIndex: number) => {
if (dayIndex === 0) {
message.warning("不能删除即时消息");
return;
setUploadingIndex(`${uploadingDay}-${uploadingMsgIdx}`);
try {
const url = await uploadFile(file);
// 更新对应消息的coverImage
setDayPlans(prev => {
const newPlans = [...prev];
const msg = newPlans[uploadingDay].messages[uploadingMsgIdx];
msg.coverImage = url;
return newPlans;
});
message.success("上传成功");
} catch (err) {
message.error("上传失败");
} finally {
setUploadingIndex(null);
setUploadingType(null);
setUploadingDay(null);
setUploadingMsgIdx(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
Modal.confirm({
title: "确认删除",
content: `确定要删除第${dayPlans[dayIndex].day}天的消息计划吗?`,
onOk: () => {
const updatedPlans = dayPlans.filter((_, index) => index !== dayIndex);
// 重新计算天数
const recalculatedPlans = updatedPlans.map((plan, index) => ({
...plan,
day: index,
}));
setDayPlans(recalculatedPlans);
onChange({ ...formData, messagePlans: recalculatedPlans });
message.success(`已删除第${dayPlans[dayIndex].day}天的消息计划`);
},
});
};
const items = dayPlans.map((plan, dayIndex) => ({
key: plan.day.toString(),
label: plan.day === 0 ? "即时消息" : `${plan.day}`,
label: (
<div
style={{
display: "flex",
alignItems: "center",
gap: "2px",
}}
>
<span>{plan.day === 0 ? "即时消息" : `${plan.day}`}</span>
{dayIndex > 0 && (
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={e => {
e.stopPropagation();
handleRemoveDayPlan(dayIndex);
}}
style={{
padding: "0 4px",
minWidth: "auto",
color: "#ff4d4f",
fontSize: "12px",
}}
title="删除此天计划"
/>
)}
</div>
),
children: (
<div className={styles["messages-day-panel"]}>
{plan.messages.map((message, messageIndex) => (
@@ -404,43 +386,16 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
style={{ marginBottom: 8 }}
/>
<div style={{ marginBottom: 8 }}>
{message.coverImage ? (
<div
style={{
position: "relative",
display: "inline-block",
}}
>
<img
src={message.coverImage}
alt="封面"
style={{ width: 120, borderRadius: 6 }}
/>
<Button
size="small"
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: undefined,
})
}
style={{ position: "absolute", top: 0, right: 0 }}
>
<CloseOutlined />
</Button>
</div>
) : (
<Button
icon={<UploadOutlined />}
loading={
uploadingIndex === `${dayIndex}-${messageIndex}`
}
onClick={() =>
triggerUpload(dayIndex, messageIndex, "miniprogram")
}
>
</Button>
)}
<MainImgUpload
value={message.coverImage || ""}
onChange={url =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: url,
})
}
maxSize={5}
showPreview={true}
/>
</div>
</>
)}
@@ -478,78 +433,83 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
style={{ marginBottom: 8 }}
/>
<div style={{ marginBottom: 8 }}>
{message.coverImage ? (
<div
style={{
position: "relative",
display: "inline-block",
}}
>
<img
src={message.coverImage}
alt="封面"
style={{ width: 120, borderRadius: 6 }}
/>
<Button
size="small"
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: undefined,
})
}
style={{ position: "absolute", top: 0, right: 0 }}
>
<CloseOutlined />
</Button>
</div>
) : (
<Button
icon={<UploadOutlined />}
loading={
uploadingIndex === `${dayIndex}-${messageIndex}`
}
onClick={() =>
triggerUpload(dayIndex, messageIndex, "link")
}
>
</Button>
)}
<MainImgUpload
value={message.coverImage || ""}
onChange={url =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: url,
})
}
maxSize={5}
showPreview={true}
/>
</div>
</>
)}
{/* 群邀请消息 */}
{message.type === "group" && (
<div style={{ marginBottom: 8 }}>
<Button onClick={() => setIsGroupSelectOpen(true)}>
{selectedGroupId
? mockGroups.find(g => g.id === selectedGroupId)?.name
: "选择邀请入的群"}
</Button>
<GroupSelection
selectedGroups={message.groupIds || []}
onSelect={groupIds =>
handleUpdateMessage(dayIndex, messageIndex, {
groupIds: groupIds,
})
}
placeholder="选择邀请入的群"
showSelectedList={true}
selectedListMaxHeight={200}
/>
</div>
)}
{/* 图片/视频/文件消息 */}
{(message.type === "image" ||
message.type === "video" ||
message.type === "file") && (
{/* 图片消息 */}
{message.type === "image" && (
<div style={{ marginBottom: 8 }}>
<Button
icon={<UploadOutlined />}
onClick={() =>
handleFileUpload(
dayIndex,
messageIndex,
message.type as any,
)
<ImageUpload
value={message.content ? [message.content] : []}
onChange={urls =>
handleUpdateMessage(dayIndex, messageIndex, {
content: urls[0] || "",
})
}
>
{message.type === "image"
? "图片"
: message.type === "video"
? "视频"
: "文件"}
</Button>
count={10}
accept="image/*"
/>
</div>
)}
{/* 视频消息 */}
{message.type === "video" && (
<div style={{ marginBottom: 8 }}>
<VideoUpload
value={message.content || ""}
onChange={url => {
const videoUrl = Array.isArray(url) ? url[0] || "" : url;
handleUpdateMessage(dayIndex, messageIndex, {
content: videoUrl,
});
}}
maxSize={50}
maxCount={5}
showPreview={true}
/>
</div>
)}
{/* 文件消息 */}
{message.type === "file" && (
<div style={{ marginBottom: 8 }}>
<FileUpload
value={message.content || ""}
onChange={url => {
const fileUrl = Array.isArray(url) ? url[0] || "" : url;
handleUpdateMessage(dayIndex, messageIndex, {
content: fileUrl,
});
}}
maxSize={10}
maxCount={10}
showPreview={true}
acceptTypes={["excel", "word", "ppt"]}
/>
</div>
)}
</div>
@@ -579,12 +539,7 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
items={items}
className={styles["messages-tab"]}
/>
<div className={styles["messages-footer"]}>
<Button onClick={onPrev}></Button>
<Button type="primary" onClick={onNext}>
</Button>
</div>
{/* 添加天数计划弹窗 */}
<Modal
title="添加消息计划"
@@ -603,41 +558,6 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
{dayPlans.length}
</Button>
</Modal>
{/* 选择群聊弹窗 */}
<Modal
title="选择群聊"
open={isGroupSelectOpen}
onCancel={() => setIsGroupSelectOpen(false)}
onOk={() => {
handleSelectGroup(selectedGroupId);
setIsGroupSelectOpen(false);
}}
>
<div>
{mockGroups.map(group => (
<div
key={group.id}
className={
styles["messages-group-select-item"] +
(selectedGroupId === group.id ? " " + styles.selected : "")
}
onClick={() => handleSelectGroup(group.id)}
>
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">
{group.memberCount}
</div>
</div>
))}
</div>
</Modal>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
);
};

View File

@@ -15,7 +15,6 @@
margin-bottom: 16px;
}
.messages-day-panel {
background: #fafbfc;
border-radius: 10px;
margin-bottom: 16px;
}

View File

@@ -0,0 +1,111 @@
# 上传组件测试页面
## 功能说明
这是一个完整的上传组件测试页面,用于演示和测试各种上传组件的功能。
## 包含的组件
### 1. 图片上传组件 (UploadComponent)
- 支持多图片上传
- 可设置最大上传数量
- 文件类型验证
- 文件大小限制 (5MB)
- 删除确认功能
### 2. 头像上传组件 (AvatarUpload)
- 圆形头像显示
- 支持尺寸调节
- 单张图片上传
- 悬停效果
### 3. 视频上传组件 (VideoUpload)
- 支持视频文件上传
- 文件大小限制 (50MB)
- 上传进度显示
## 测试功能
### 控制面板
- 图片上传数量限制调节 (1-20张)
- 头像尺寸调节 (60-200px)
- 各组件禁用状态切换
### 表单集成测试
- 模拟真实表单场景
- 包含文本输入、文本域、图片、头像、视频上传
- 展示组件在表单中的使用方式
### 操作功能
- 提交表单数据 (输出到控制台)
- 加载模拟数据
- 重置所有数据
- 页面刷新功能
### 数据展示
- 实时显示所有组件的当前状态
- JSON格式展示便于调试
## 使用Layout组件
页面使用了Layout组件进行布局包含
1. **自定义头部**
- 返回按钮
- 页面标题和图标
- 搜索功能
- 刷新功能
2. **主要内容区域**
- 响应式设计
- 滚动支持
- 加载状态显示
3. **样式特点**
- 响应式设计
- 暗色主题支持
- 现代化UI设计
## 访问方式
访问路径:`/mobile/test/upload`
## 开发说明
### 文件结构
```
src/pages/mobile/test/
├── upload.tsx # 主测试页面
├── upload.module.scss # 样式文件
└── README.md # 说明文档
```
### 依赖组件
- Layout: 页面布局组件
- PopupHeader: 头部搜索组件
- UploadComponent: 图片上传组件
- AvatarUpload: 头像上传组件
- VideoUpload: 视频上传组件
### 技术栈
- React 18
- TypeScript
- Antd Mobile
- SCSS Modules
## 注意事项
1. 上传功能需要配置正确的API地址
2. 文件上传需要有效的认证token
3. 测试数据使用外部图片服务,需要网络连接
4. 建议在移动端环境下测试以获得最佳体验

View File

@@ -1,6 +1,10 @@
import React from "react";
import { Card, Button, Space, Typography, Tag } from "antd";
import { MessageOutlined, SelectOutlined } from "@ant-design/icons";
import {
MessageOutlined,
SelectOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { isDevelopment } from "@/utils/env";
import Layout from "@/components/Layout/Layout";
@@ -31,7 +35,7 @@ const TestIndex: React.FC = () => {
icon={<MessageOutlined />}
size="large"
block
onClick={() => navigate("/test/postMessage")}
onClick={() => navigate("/test/iframe")}
>
UniApp桥接测试
</Button>
@@ -44,6 +48,15 @@ const TestIndex: React.FC = () => {
>
</Button>
<Button
icon={<UploadOutlined />}
size="large"
block
onClick={() => navigate("/test/upload")}
>
</Button>
</Space>
</Card>

View File

@@ -0,0 +1,354 @@
.container {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
.customHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
.backButton {
padding: 4px;
border: none;
background: transparent;
color: #666;
font-size: 18px;
&:hover {
color: #1890ff;
}
}
.headerTitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: bold;
color: #333;
.headerIcon {
font-size: 20px;
color: #1890ff;
}
}
}
}
.controlPanel {
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
}
.controlItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
span {
font-size: 14px;
color: #333;
}
}
}
.testSection {
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
p {
font-size: 12px;
color: #666;
margin-bottom: 16px;
}
.result {
margin-top: 16px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
h4 {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.urlList {
.urlItem {
display: flex;
align-items: flex-start;
margin-bottom: 4px;
font-size: 12px;
span:first-child {
color: #666;
margin-right: 8px;
min-width: 20px;
flex-shrink: 0;
}
.url {
color: #1890ff;
word-break: break-all;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
flex: 1;
min-width: 0; // 确保flex项目可以收缩
}
}
}
.emptyText {
color: #999;
font-style: italic;
}
}
}
.form {
.formItem {
margin-bottom: 16px;
label {
display: block;
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
input,
textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
&:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
&::placeholder {
color: #bfbfbf;
}
}
textarea {
resize: vertical;
min-height: 80px;
}
}
}
.actionPanel {
margin-bottom: 16px;
}
.dataPanel {
h3 {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
}
.dataContent {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
max-height: 300px;
overflow-y: auto;
pre {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: #333;
white-space: pre-wrap;
word-break: break-all;
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.container {
padding: 12px;
.customHeader {
padding: 8px 12px;
.headerLeft {
.headerTitle {
font-size: 14px;
.headerIcon {
font-size: 18px;
}
}
}
}
.testSection {
.result {
.urlList {
.urlItem {
font-size: 11px;
}
}
}
}
.dataPanel {
.dataContent {
pre {
font-size: 11px;
}
}
}
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.container {
background: #1f1f1f;
.customHeader {
background: #2a2a2a;
border-bottom-color: #434343;
.headerLeft {
.backButton {
color: #ccc;
&:hover {
color: #40a9ff;
}
}
.headerTitle {
color: #fff;
.headerIcon {
color: #40a9ff;
}
}
}
}
.testSection {
h3 {
color: #fff;
}
p {
color: #ccc;
}
.result {
background: #2a2a2a;
h4 {
color: #fff;
}
.urlList {
.urlItem {
span:first-child {
color: #ccc;
}
.url {
color: #40a9ff;
}
}
}
// 单个URL项头像、视频的样式
.urlItem {
.url {
color: #40a9ff;
}
}
.emptyText {
color: #888;
}
}
}
.form {
.formItem {
label {
color: #fff;
}
input,
textarea {
background: #2a2a2a;
border-color: #434343;
color: #fff;
&:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(64, 169, 255, 0.2);
}
&::placeholder {
color: #666;
}
}
}
}
.dataPanel {
h3 {
color: #fff;
}
.dataContent {
background: #2a2a2a;
pre {
color: #fff;
}
}
}
}
}

View File

@@ -0,0 +1,423 @@
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/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";
// 错误边界组件
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Error caught by boundary:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 20, textAlign: "center" }}>
<h3></h3>
<p>: {this.state.error?.message}</p>
<Button
onClick={() => this.setState({ hasError: false, error: null })}
>
</Button>
</div>
);
}
return this.props.children;
}
}
const UploadTestPage: React.FC = () => {
// 搜索状态
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(false);
// 图片上传状态
const [imageUrls, setImageUrls] = useState<string[]>([]);
const [imageCount, setImageCount] = useState(9);
const [imageDisabled, setImageDisabled] = useState(false);
// 头像上传状态
const [avatarUrl, setAvatarUrl] = useState<string>("");
const [avatarSize, setAvatarSize] = useState(100);
const [avatarDisabled, setAvatarDisabled] = useState(false);
// 视频上传状态
const [videoUrl, setVideoUrl] = useState<string>("");
const [videoUrls, setVideoUrls] = useState<string[]>([]);
const [videoDisabled, setVideoDisabled] = useState(false);
const [videoCount, setVideoCount] = useState(1);
// 文件上传状态
const [fileUrl, setFileUrl] = useState<string>("");
const [fileUrls, setFileUrls] = useState<string[]>([]);
const [fileDisabled, setFileDisabled] = useState(false);
const [fileCount, setFileCount] = useState(1);
const [fileTypes, setFileTypes] = useState<string[]>([
"excel",
"word",
"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}>
{/* 图片上传测试 */}
<ErrorBoundary>
<Card className={styles.testSection}>
<h3></h3>
<p></p>
<ImageUpload
value={imageUrls}
onChange={setImageUrls}
count={imageCount}
accept="image/*"
disabled={imageDisabled}
/>
<div className={styles.result}>
<h4>URLs:</h4>
<div className={styles.urlList}>
{imageUrls.length > 0 ? (
imageUrls.map((url, index) => (
<div key={index} className={styles.urlItem}>
<span>{index + 1}.</span>
<span className={styles.url}>
{typeof url === "string" ? url : "无效URL"}
</span>
</div>
))
) : (
<span className={styles.emptyText}></span>
)}
</div>
</div>
</Card>
</ErrorBoundary>
{/* 头像上传测试 */}
<ErrorBoundary>
<Card className={styles.testSection}>
<h3></h3>
<p></p>
<AvatarUpload
value={avatarUrl}
onChange={setAvatarUrl}
disabled={avatarDisabled}
size={avatarSize}
/>
<div className={styles.result}>
<h4>URL:</h4>
<div className={styles.urlList}>
<div className={styles.urlItem}>
{avatarUrl ? (
<div className={styles.url}>
{typeof avatarUrl === "string" ? avatarUrl : "无效URL"}
</div>
) : (
<span className={styles.emptyText}></span>
)}
</div>
</div>
</div>
</Card>
</ErrorBoundary>
{/* 视频上传测试 */}
<ErrorBoundary>
<Card className={styles.testSection}>
<h3></h3>
<p>50MB</p>
{/* 视频上传控制面板 */}
<div className={styles.controlPanel}>
<div className={styles.controlItem}>
<span>:</span>
<Space>
<Button
size="small"
onClick={() => setVideoCount(Math.max(1, videoCount - 1))}
>
-
</Button>
<span>{videoCount}</span>
<Button
size="small"
onClick={() => setVideoCount(Math.min(10, videoCount + 1))}
>
+
</Button>
</Space>
</div>
</div>
<VideoUpload
value={videoCount === 1 ? videoUrl : videoUrls}
onChange={url => {
if (videoCount === 1) {
setVideoUrl(url as string);
} else {
setVideoUrls(url as string[]);
}
}}
disabled={videoDisabled}
maxSize={50}
showPreview={true}
maxCount={videoCount}
/>
<div className={styles.result}>
<h4>URL:</h4>
<div className={styles.urlList}>
{videoCount === 1 ? (
<div className={styles.urlItem}>
{videoUrl ? (
<div className={styles.url}>
{typeof videoUrl === "string" ? videoUrl : "无效URL"}
</div>
) : (
<span className={styles.emptyText}></span>
)}
</div>
) : videoUrls.length > 0 ? (
videoUrls.map((url, index) => (
<div key={index} className={styles.urlItem}>
<span>{index + 1}.</span>
<div className={styles.url}>
{typeof url === "string" ? url : "无效URL"}
</div>
</div>
))
) : (
<span className={styles.emptyText}></span>
)}
</div>
</div>
</Card>
</ErrorBoundary>
{/* 文件上传测试 */}
<ErrorBoundary>
<Card className={styles.testSection}>
<h3></h3>
<p>ExcelWordPPT格式文件上传</p>
{/* 文件上传控制面板 */}
<div className={styles.controlPanel}>
<div className={styles.controlItem}>
<span>:</span>
<Space>
<Button
size="small"
onClick={() => setFileCount(Math.max(1, fileCount - 1))}
>
-
</Button>
<span>{fileCount}</span>
<Button
size="small"
onClick={() => setFileCount(Math.min(10, fileCount + 1))}
>
+
</Button>
</Space>
</div>
<div className={styles.controlItem}>
<span>:</span>
<Space>
<Button
size="small"
color={fileTypes.includes("excel") ? "primary" : "default"}
onClick={() => {
if (fileTypes.includes("excel")) {
setFileTypes(fileTypes.filter(t => t !== "excel"));
} else {
setFileTypes([...fileTypes, "excel"]);
}
}}
>
Excel
</Button>
<Button
size="small"
color={fileTypes.includes("word") ? "primary" : "default"}
onClick={() => {
if (fileTypes.includes("word")) {
setFileTypes(fileTypes.filter(t => t !== "word"));
} else {
setFileTypes([...fileTypes, "word"]);
}
}}
>
Word
</Button>
<Button
size="small"
color={fileTypes.includes("ppt") ? "primary" : "default"}
onClick={() => {
if (fileTypes.includes("ppt")) {
setFileTypes(fileTypes.filter(t => t !== "ppt"));
} else {
setFileTypes([...fileTypes, "ppt"]);
}
}}
>
PPT
</Button>
</Space>
</div>
</div>
<FileUpload
value={fileCount === 1 ? fileUrl : fileUrls}
onChange={url => {
if (fileCount === 1) {
setFileUrl(url as string);
} else {
setFileUrls(url as string[]);
}
}}
disabled={fileDisabled}
maxSize={10}
showPreview={true}
maxCount={fileCount}
acceptTypes={fileTypes}
/>
<div className={styles.result}>
<h4>URL:</h4>
<div className={styles.urlList}>
{fileCount === 1 ? (
<div className={styles.urlItem}>
{fileUrl ? (
<div className={styles.url}>
{typeof fileUrl === "string" ? fileUrl : "无效URL"}
</div>
) : (
<span className={styles.emptyText}></span>
)}
</div>
) : fileUrls.length > 0 ? (
fileUrls.map((url, index) => (
<div key={index} className={styles.urlItem}>
<span>{index + 1}.</span>
<div className={styles.url}>
{typeof url === "string" ? url : "无效URL"}
</div>
</div>
))
) : (
<span className={styles.emptyText}></span>
)}
</div>
</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>
);
};
export default UploadTestPage;

View File

@@ -1,12 +0,0 @@
import IframeDebugPage from "@/pages/iframe";
// iframe 调试路由
const iframeRoutes = [
{
path: "/iframe",
element: <IframeDebugPage />,
auth: false, // 不需要认证,方便调试
},
];
export default iframeRoutes;

View File

@@ -1,6 +1,7 @@
import SelectTest from "@/pages/mobile/test/select";
import PostMessageTest from "@/pages/mobile/test/postMessage";
import TestIndex from "@/pages/mobile/test/index";
import UploadTest from "@/pages/mobile/test/upload";
import IframeDebugPage from "@/pages/iframe";
import { DEV_FEATURES } from "@/utils/env";
// 只在开发环境启用测试路由
@@ -16,6 +17,16 @@ const componentTestRoutes = DEV_FEATURES.SHOW_TEST_PAGES
element: <SelectTest />,
auth: true,
},
{
path: "/test/upload",
element: <UploadTest />,
auth: true,
},
{
path: "/test/iframe",
element: <IframeDebugPage />,
auth: false, // 不需要认证,方便调试
},
]
: [];