Merge branch 'yongpxu-dev' into yongpxu-dev2
This commit is contained in:
484
nkebao/src/components/Upload/AvatarUpload/index.module.scss
Normal file
484
nkebao/src/components/Upload/AvatarUpload/index.module.scss
Normal file
@@ -0,0 +1,484 @@
|
||||
.uploadContainer {
|
||||
width: 100%;
|
||||
|
||||
// 自定义上传组件样式
|
||||
:global {
|
||||
.adm-image-uploader {
|
||||
.adm-image-uploader-upload-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.adm-image-uploader-upload-button-icon {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.adm-image-uploader-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.adm-image-uploader-item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.adm-image-uploader-item-delete {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.adm-image-uploader-item-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.uploadContainer.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.uploadContainer.error {
|
||||
:global {
|
||||
.adm-image-uploader-upload-button {
|
||||
border-color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.uploadContainer {
|
||||
:global {
|
||||
.adm-image-uploader {
|
||||
.adm-image-uploader-upload-button,
|
||||
.adm-image-uploader-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.adm-image-uploader-upload-button-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 头像上传组件样式
|
||||
.avatarUploadContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.avatarWrapper {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
border: 2px solid #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.avatarUploadOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.uploadLoading {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarDeleteBtn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: #ff7875;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .avatarUploadOverlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarTip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
// 视频上传组件样式
|
||||
.videoUploadContainer {
|
||||
width: 100%;
|
||||
|
||||
.videoUploadButton {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.uploadingIcon {
|
||||
font-size: 32px;
|
||||
color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.uploadingText {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadProgress {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.uploadIcon {
|
||||
font-size: 48px;
|
||||
color: #1890ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .uploadIcon {
|
||||
transform: scale(1.1);
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoItem {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.videoItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.videoIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.videoInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.videoName {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.videoSize {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.videoActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.previewBtn {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemProgress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoPreview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
video {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题支持
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.videoUploadContainer {
|
||||
.videoUploadButton {
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
|
||||
border-color: #434343;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
.uploadingText {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoItem {
|
||||
background: #2a2a2a;
|
||||
border-color: #434343;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.videoItemContent {
|
||||
.videoInfo {
|
||||
.videoName {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.videoSize {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.videoActions {
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
&:hover {
|
||||
background: #434343;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 || "上传失败");
|
||||
}
|
||||
265
nkebao/src/components/Upload/FileUpload/index.module.scss
Normal file
265
nkebao/src/components/Upload/FileUpload/index.module.scss
Normal file
@@ -0,0 +1,265 @@
|
||||
.fileUploadContainer {
|
||||
width: 100%;
|
||||
|
||||
// 覆盖 antd Upload 组件的默认样式
|
||||
:global {
|
||||
.ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text .ant-upload-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fileUploadButton {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: clamp(90px, 20vw, 180px);
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.uploadingIcon {
|
||||
font-size: clamp(24px, 4vw, 32px);
|
||||
color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.uploadingText {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadProgress {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.uploadIcon {
|
||||
font-size: clamp(50px, 6vw, 48px);
|
||||
color: #1890ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
font-size: clamp(14px, 2.5vw, 16px);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
font-size: clamp(10px, 1.5vw, 14px);
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .uploadIcon {
|
||||
transform: scale(1.1);
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fileItem {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.fileItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.fileIcon {
|
||||
width: clamp(28px, 5vw, 40px);
|
||||
height: clamp(28px, 5vw, 40px);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(14px, 2.5vw, 18px);
|
||||
flex-shrink: 0;
|
||||
|
||||
// Excel文件图标样式
|
||||
:global(.anticon-file-excel) {
|
||||
color: #217346;
|
||||
background: rgba(33, 115, 70, 0.1);
|
||||
}
|
||||
|
||||
// Word文件图标样式
|
||||
:global(.anticon-file-word) {
|
||||
color: #2b579a;
|
||||
background: rgba(43, 87, 154, 0.1);
|
||||
}
|
||||
|
||||
// PPT文件图标样式
|
||||
:global(.anticon-file-ppt) {
|
||||
color: #d24726;
|
||||
background: rgba(210, 71, 38, 0.1);
|
||||
}
|
||||
|
||||
// 默认文件图标样式
|
||||
:global(.anticon-file) {
|
||||
color: #666;
|
||||
background: rgba(102, 102, 102, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.fileName {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileSize {
|
||||
font-size: clamp(10px, 1.5vw, 12px);
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.previewBtn {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemProgress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.filePreview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
iframe {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.fileUploadContainer.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.fileUploadContainer.error {
|
||||
.fileUploadButton {
|
||||
border-color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
459
nkebao/src/components/Upload/FileUpload/index.tsx
Normal file
459
nkebao/src/components/Upload/FileUpload/index.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, message, Progress, Button, Modal } from "antd";
|
||||
import {
|
||||
LoadingOutlined,
|
||||
FileOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
CloudUploadOutlined,
|
||||
FileExcelOutlined,
|
||||
FileWordOutlined,
|
||||
FilePptOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface FileUploadProps {
|
||||
value?: string | string[]; // 支持单个字符串或字符串数组
|
||||
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
showPreview?: boolean; // 是否显示预览
|
||||
maxCount?: number; // 最大上传数量,默认为1
|
||||
acceptTypes?: string[]; // 接受的文件类型
|
||||
}
|
||||
|
||||
const FileUpload: React.FC<FileUploadProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
maxSize = 10,
|
||||
showPreview = true,
|
||||
maxCount = 1,
|
||||
acceptTypes = ["excel", "word", "ppt"],
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
||||
// 文件类型配置
|
||||
const fileTypeConfig = {
|
||||
excel: {
|
||||
accept: ".xlsx,.xls",
|
||||
mimeTypes: [
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
],
|
||||
icon: FileExcelOutlined,
|
||||
name: "Excel文件",
|
||||
extensions: ["xlsx", "xls"],
|
||||
},
|
||||
word: {
|
||||
accept: ".docx,.doc",
|
||||
mimeTypes: [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
],
|
||||
icon: FileWordOutlined,
|
||||
name: "Word文件",
|
||||
extensions: ["docx", "doc"],
|
||||
},
|
||||
ppt: {
|
||||
accept: ".pptx,.ppt",
|
||||
mimeTypes: [
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
],
|
||||
icon: FilePptOutlined,
|
||||
name: "PPT文件",
|
||||
extensions: ["pptx", "ppt"],
|
||||
},
|
||||
};
|
||||
|
||||
// 生成accept字符串
|
||||
const generateAcceptString = () => {
|
||||
return acceptTypes
|
||||
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.accept)
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
};
|
||||
|
||||
// 获取文件类型信息
|
||||
const getFileTypeInfo = (file: File) => {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
for (const type of acceptTypes) {
|
||||
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
|
||||
if (config && config.extensions.includes(extension || "")) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取文件图标
|
||||
const getFileIcon = (file: File) => {
|
||||
const typeInfo = getFileTypeInfo(file);
|
||||
return typeInfo ? typeInfo.icon : FileOutlined;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
// 处理单个字符串或字符串数组
|
||||
const urls = Array.isArray(value) ? value : [value];
|
||||
const files: UploadFile[] = urls.map((url, index) => ({
|
||||
uid: `file-${index}`,
|
||||
name: `document-${index + 1}`,
|
||||
status: "done",
|
||||
url: url || "",
|
||||
}));
|
||||
setFileList(files);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 文件验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const typeInfo = getFileTypeInfo(file);
|
||||
if (!typeInfo) {
|
||||
const allowedTypes = acceptTypes
|
||||
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.name)
|
||||
.filter(Boolean)
|
||||
.join("、");
|
||||
message.error(`只能上传${allowedTypes}!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(`文件大小不能超过${maxSize}MB!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 处理文件变化
|
||||
const handleChange: UploadProps["onChange"] = info => {
|
||||
// 更新 fileList,确保所有 URL 都是字符串
|
||||
const updatedFileList = info.fileList.map(file => {
|
||||
let url = "";
|
||||
|
||||
if (file.url) {
|
||||
url = file.url;
|
||||
} else if (file.response) {
|
||||
// 处理响应对象
|
||||
if (typeof file.response === "string") {
|
||||
url = file.response;
|
||||
} else if (file.response.data) {
|
||||
url =
|
||||
typeof file.response.data === "string"
|
||||
? file.response.data
|
||||
: file.response.data.url || "";
|
||||
} else if (file.response.url) {
|
||||
url = file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
|
||||
setFileList(updatedFileList);
|
||||
|
||||
// 处理上传状态
|
||||
if (info.file.status === "uploading") {
|
||||
setLoading(true);
|
||||
// 模拟上传进度
|
||||
const progress = Math.min(99, Math.random() * 100);
|
||||
setUploadProgress(progress);
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
message.success("文件上传成功!");
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
uploadedUrl =
|
||||
typeof info.file.response.data === "string"
|
||||
? info.file.response.data
|
||||
: info.file.response.data.url || "";
|
||||
} else if (info.file.response.url) {
|
||||
uploadedUrl = info.file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
if (maxCount === 1) {
|
||||
// 单个文件模式
|
||||
onChange?.(uploadedUrl);
|
||||
} else {
|
||||
// 多个文件模式
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
setUploadProgress(0);
|
||||
message.error("上传失败,请重试");
|
||||
} else if (info.file.status === "removed") {
|
||||
if (maxCount === 1) {
|
||||
onChange?.("");
|
||||
} else {
|
||||
// 多个文件模式,移除对应的文件
|
||||
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const removedIndex = info.fileList.findIndex(
|
||||
f => f.uid === info.file.uid,
|
||||
);
|
||||
if (removedIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== removedIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = (file?: UploadFile) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这个文件吗?",
|
||||
okText: "确定",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
if (maxCount === 1) {
|
||||
setFileList([]);
|
||||
onChange?.("");
|
||||
} else if (file) {
|
||||
// 多个文件模式,删除指定文件
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
|
||||
if (fileIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== fileIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
message.success("文件已删除");
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
// 预览文件
|
||||
const handlePreview = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
// 获取文件大小显示
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// 自定义上传按钮
|
||||
const uploadButton = (
|
||||
<div className={style.fileUploadButton}>
|
||||
{loading ? (
|
||||
<div className={style.uploadingContainer}>
|
||||
<div className={style.uploadingIcon}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
<div className={style.uploadingText}>上传中...</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#1890ff"
|
||||
className={style.uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.uploadContent}>
|
||||
<div className={style.uploadIcon}>
|
||||
<CloudUploadOutlined />
|
||||
</div>
|
||||
<div className={style.uploadText}>
|
||||
<div className={style.uploadTitle}>
|
||||
{maxCount === 1
|
||||
? "上传文档"
|
||||
: `上传文档 (${fileList.length}/${maxCount})`}
|
||||
</div>
|
||||
<div className={style.uploadSubtitle}>
|
||||
支持{" "}
|
||||
{acceptTypes
|
||||
.map(
|
||||
type =>
|
||||
fileTypeConfig[type as keyof typeof fileTypeConfig]?.name,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join("、")}
|
||||
,最大 {maxSize}MB
|
||||
{maxCount > 1 && `,最多上传 ${maxCount} 个文件`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 自定义文件列表项
|
||||
const customItemRender = (
|
||||
originNode: React.ReactElement,
|
||||
file: UploadFile,
|
||||
) => {
|
||||
const FileIcon = file.originFileObj
|
||||
? getFileIcon(file.originFileObj)
|
||||
: FileOutlined;
|
||||
|
||||
if (file.status === "uploading") {
|
||||
return (
|
||||
<div className={style.fileItem}>
|
||||
<div className={style.fileItemContent}>
|
||||
<div className={style.fileIcon}>
|
||||
<FileIcon />
|
||||
</div>
|
||||
<div className={style.fileInfo}>
|
||||
<div className={style.fileName}>{file.name}</div>
|
||||
<div className={style.fileSize}>
|
||||
{file.size ? formatFileSize(file.size) : "计算中..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.fileActions}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
strokeColor="#1890ff"
|
||||
className={style.itemProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status === "done") {
|
||||
return (
|
||||
<div className={style.fileItem}>
|
||||
<div className={style.fileItemContent}>
|
||||
<div className={style.fileIcon}>
|
||||
<FileIcon />
|
||||
</div>
|
||||
<div className={style.fileInfo}>
|
||||
<div className={style.fileName}>{file.name}</div>
|
||||
<div className={style.fileSize}>
|
||||
{file.size ? formatFileSize(file.size) : "未知大小"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.fileActions}>
|
||||
{showPreview && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(file.url || "")}
|
||||
className={style.previewBtn}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return originNode;
|
||||
};
|
||||
|
||||
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
|
||||
|
||||
return (
|
||||
<div className={`${style.fileUploadContainer} ${className || ""}`}>
|
||||
<Upload
|
||||
name="file"
|
||||
headers={{
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
}}
|
||||
action={action}
|
||||
multiple={maxCount > 1}
|
||||
fileList={fileList}
|
||||
accept={generateAcceptString()}
|
||||
listType="text"
|
||||
showUploadList={{
|
||||
showPreviewIcon: false,
|
||||
showRemoveIcon: false,
|
||||
showDownloadIcon: false,
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
beforeUpload={beforeUpload}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
maxCount={maxCount}
|
||||
itemRender={customItemRender}
|
||||
>
|
||||
{fileList.length >= maxCount ? null : uploadButton}
|
||||
</Upload>
|
||||
|
||||
{/* 文件预览模态框 */}
|
||||
<Modal
|
||||
title="文件预览"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
centered
|
||||
>
|
||||
<div className={style.filePreview}>
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
style={{ width: "100%", height: "500px", border: "none" }}
|
||||
title="文件预览"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
@@ -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 || "上传失败");
|
||||
}
|
||||
484
nkebao/src/components/Upload/ImageUpload/index.module.scss
Normal file
484
nkebao/src/components/Upload/ImageUpload/index.module.scss
Normal file
@@ -0,0 +1,484 @@
|
||||
.uploadContainer {
|
||||
width: 100%;
|
||||
|
||||
// 自定义上传组件样式
|
||||
:global {
|
||||
.adm-image-uploader {
|
||||
.adm-image-uploader-upload-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.adm-image-uploader-upload-button-icon {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.adm-image-uploader-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.adm-image-uploader-item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.adm-image-uploader-item-delete {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.adm-image-uploader-item-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.uploadContainer.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.uploadContainer.error {
|
||||
:global {
|
||||
.adm-image-uploader-upload-button {
|
||||
border-color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.uploadContainer {
|
||||
:global {
|
||||
.adm-image-uploader {
|
||||
.adm-image-uploader-upload-button,
|
||||
.adm-image-uploader-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.adm-image-uploader-upload-button-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 头像上传组件样式
|
||||
.avatarUploadContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.avatarWrapper {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
border: 2px solid #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.avatarUploadOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.uploadLoading {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarDeleteBtn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: #ff7875;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .avatarUploadOverlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarTip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
// 视频上传组件样式
|
||||
.videoUploadContainer {
|
||||
width: 100%;
|
||||
|
||||
.videoUploadButton {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.uploadingIcon {
|
||||
font-size: 32px;
|
||||
color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.uploadingText {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadProgress {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.uploadIcon {
|
||||
font-size: 48px;
|
||||
color: #1890ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .uploadIcon {
|
||||
transform: scale(1.1);
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoItem {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.videoItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.videoIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.videoInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.videoName {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.videoSize {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.videoActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.previewBtn {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemProgress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoPreview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
video {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题支持
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.videoUploadContainer {
|
||||
.videoUploadButton {
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
|
||||
border-color: #434343;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
.uploadingText {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoItem {
|
||||
background: #2a2a2a;
|
||||
border-color: #434343;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.videoItemContent {
|
||||
.videoInfo {
|
||||
.videoName {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.videoSize {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.videoActions {
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
&:hover {
|
||||
background: #434343;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
291
nkebao/src/components/Upload/MainImgUpload/index.module.scss
Normal file
291
nkebao/src/components/Upload/MainImgUpload/index.module.scss
Normal file
@@ -0,0 +1,291 @@
|
||||
.mainImgUploadContainer {
|
||||
width: 100%;
|
||||
|
||||
// 覆盖 antd Upload 组件的默认样式
|
||||
:global {
|
||||
.ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text .ant-upload-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mainImgUploadButton {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: clamp(90px, 20vw, 180px);
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.uploadingIcon {
|
||||
font-size: clamp(24px, 4vw, 32px);
|
||||
color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.uploadingText {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.uploadIcon {
|
||||
font-size: clamp(50px, 6vw, 48px);
|
||||
color: #1890ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
font-size: clamp(14px, 2.5vw, 16px);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
font-size: clamp(10px, 1.5vw, 14px);
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .uploadIcon {
|
||||
transform: scale(1.1);
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainImgItem {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.mainImgItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.mainImgIcon {
|
||||
width: clamp(28px, 5vw, 40px);
|
||||
height: clamp(28px, 5vw, 40px);
|
||||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: clamp(14px, 2.5vw, 18px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mainImgInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.mainImgName {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mainImgSize {
|
||||
font-size: clamp(10px, 1.5vw, 12px);
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.mainImgActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.previewBtn {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainImgPreview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: clamp(90px, 20vw, 180px);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
|
||||
.mainImgImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mainImgOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
border-radius: 8px;
|
||||
|
||||
.mainImgActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
color: #1890ff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mainImgOverlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.mainImgUploadContainer.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.mainImgUploadContainer.error {
|
||||
.mainImgUploadButton {
|
||||
border-color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
313
nkebao/src/components/Upload/MainImgUpload/index.tsx
Normal file
313
nkebao/src/components/Upload/MainImgUpload/index.tsx
Normal 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}>
|
||||
支持 JPG、PNG、GIF 等格式,最大 {maxSize}MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 自定义文件列表项
|
||||
const customItemRender = (
|
||||
originNode: React.ReactElement,
|
||||
file: UploadFile,
|
||||
) => {
|
||||
if (file.status === "uploading") {
|
||||
return (
|
||||
<div className={style.mainImgItem}>
|
||||
<div className={style.mainImgItemContent}>
|
||||
<div className={style.mainImgIcon}>
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<div className={style.mainImgInfo}>
|
||||
<div className={style.mainImgName}>{file.name}</div>
|
||||
<div className={style.mainImgSize}>
|
||||
{file.size ? formatFileSize(file.size) : "计算中..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.mainImgActions}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove()}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status === "done") {
|
||||
return (
|
||||
<div className={style.mainImgItem}>
|
||||
<div className={style.mainImgItemContent}>
|
||||
<div className={style.mainImgIcon}>
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<div className={style.mainImgInfo}>
|
||||
<div className={style.mainImgName}>{file.name}</div>
|
||||
<div className={style.mainImgSize}>
|
||||
{file.size ? formatFileSize(file.size) : "未知大小"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.mainImgActions}>
|
||||
{showPreview && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(file.url || "")}
|
||||
className={style.previewBtn}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove()}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.mainImgPreview}>
|
||||
<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;
|
||||
@@ -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. 支持编辑、删除、预览等操作
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 文件类型不匹配时会显示错误提示
|
||||
- 文件大小超限时会显示错误提示
|
||||
- 上传失败时会显示错误提示
|
||||
- 网络错误时会显示错误提示
|
||||
|
||||
## 头像上传特性
|
||||
|
||||
- **圆形显示**: 头像以圆形方式显示
|
||||
- **占位符**: 无头像时显示用户图标
|
||||
- **上传覆盖**: 鼠标悬停显示上传图标
|
||||
- **删除功能**: 右上角删除按钮
|
||||
- **加载状态**: 上传时显示加载提示
|
||||
- **尺寸可调**: 支持自定义头像尺寸
|
||||
|
||||
@@ -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;
|
||||
243
nkebao/src/components/Upload/VideoUpload/index.module.scss
Normal file
243
nkebao/src/components/Upload/VideoUpload/index.module.scss
Normal file
@@ -0,0 +1,243 @@
|
||||
.videoUploadContainer {
|
||||
width: 100%;
|
||||
|
||||
// 覆盖 antd Upload 组件的默认样式
|
||||
:global {
|
||||
.ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text .ant-upload-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.videoUploadButton {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: clamp(90px, 20vw, 180px);
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.uploadingIcon {
|
||||
font-size: clamp(24px, 4vw, 32px);
|
||||
color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.uploadingText {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadProgress {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.uploadIcon {
|
||||
font-size: clamp(50px, 6vw, 48px);
|
||||
color: #1890ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
font-size: clamp(14px, 2.5vw, 16px);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
font-size: clamp(10px, 1.5vw, 14px);
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .uploadIcon {
|
||||
transform: scale(1.1);
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoItem {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.videoItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.videoIcon {
|
||||
width: clamp(28px, 5vw, 40px);
|
||||
height: clamp(28px, 5vw, 40px);
|
||||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: clamp(14px, 2.5vw, 18px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.videoInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.videoName {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.videoSize {
|
||||
font-size: clamp(10px, 1.5vw, 12px);
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.videoActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.previewBtn {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemProgress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoPreview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
video {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.videoUploadContainer.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.videoUploadContainer.error {
|
||||
.videoUploadButton {
|
||||
border-color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
381
nkebao/src/components/Upload/VideoUpload/index.tsx
Normal file
381
nkebao/src/components/Upload/VideoUpload/index.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, message, Progress, Button, Modal } from "antd";
|
||||
import {
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FileOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface VideoUploadProps {
|
||||
value?: string | string[]; // 支持单个字符串或字符串数组
|
||||
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
showPreview?: boolean; // 是否显示预览
|
||||
maxCount?: number; // 最大上传数量,默认为1
|
||||
}
|
||||
|
||||
const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
maxSize = 50,
|
||||
showPreview = true,
|
||||
maxCount = 1,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
// 处理单个字符串或字符串数组
|
||||
const urls = Array.isArray(value) ? value : [value];
|
||||
const files: UploadFile[] = urls.map((url, index) => ({
|
||||
uid: `file-${index}`,
|
||||
name: `video-${index + 1}`,
|
||||
status: "done",
|
||||
url: url || "",
|
||||
}));
|
||||
setFileList(files);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 文件验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
if (!isVideo) {
|
||||
message.error("只能上传视频文件!");
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(`视频大小不能超过${maxSize}MB!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 处理文件变化
|
||||
const handleChange: UploadProps["onChange"] = info => {
|
||||
// 更新 fileList,确保所有 URL 都是字符串
|
||||
const updatedFileList = info.fileList.map(file => {
|
||||
let url = "";
|
||||
|
||||
if (file.url) {
|
||||
url = file.url;
|
||||
} else if (file.response) {
|
||||
// 处理响应对象
|
||||
if (typeof file.response === "string") {
|
||||
url = file.response;
|
||||
} else if (file.response.data) {
|
||||
url =
|
||||
typeof file.response.data === "string"
|
||||
? file.response.data
|
||||
: file.response.data.url || "";
|
||||
} else if (file.response.url) {
|
||||
url = file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
|
||||
setFileList(updatedFileList);
|
||||
|
||||
// 处理上传状态
|
||||
if (info.file.status === "uploading") {
|
||||
setLoading(true);
|
||||
// 模拟上传进度
|
||||
const progress = Math.min(99, Math.random() * 100);
|
||||
setUploadProgress(progress);
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
message.success("视频上传成功!");
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
uploadedUrl =
|
||||
typeof info.file.response.data === "string"
|
||||
? info.file.response.data
|
||||
: info.file.response.data.url || "";
|
||||
} else if (info.file.response.url) {
|
||||
uploadedUrl = info.file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
if (maxCount === 1) {
|
||||
// 单个视频模式
|
||||
onChange?.(uploadedUrl);
|
||||
} else {
|
||||
// 多个视频模式
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
setUploadProgress(0);
|
||||
message.error("上传失败,请重试");
|
||||
} else if (info.file.status === "removed") {
|
||||
if (maxCount === 1) {
|
||||
onChange?.("");
|
||||
} else {
|
||||
// 多个视频模式,移除对应的视频
|
||||
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const removedIndex = info.fileList.findIndex(
|
||||
f => f.uid === info.file.uid,
|
||||
);
|
||||
if (removedIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== removedIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = (file?: UploadFile) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这个视频文件吗?",
|
||||
okText: "确定",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
if (maxCount === 1) {
|
||||
setFileList([]);
|
||||
onChange?.("");
|
||||
} else if (file) {
|
||||
// 多个视频模式,删除指定视频
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
|
||||
if (fileIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== fileIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
message.success("视频已删除");
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
// 预览视频
|
||||
const handlePreview = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
// 获取文件大小显示
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// 自定义上传按钮
|
||||
const uploadButton = (
|
||||
<div className={style.videoUploadButton}>
|
||||
{loading ? (
|
||||
<div className={style.uploadingContainer}>
|
||||
<div className={style.uploadingIcon}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
<div className={style.uploadingText}>上传中...</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#1890ff"
|
||||
className={style.uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.uploadContent}>
|
||||
<div className={style.uploadIcon}>
|
||||
<CloudUploadOutlined />
|
||||
</div>
|
||||
<div className={style.uploadText}>
|
||||
<div className={style.uploadTitle}>
|
||||
{maxCount === 1
|
||||
? "上传视频"
|
||||
: `上传视频 (${fileList.length}/${maxCount})`}
|
||||
</div>
|
||||
<div className={style.uploadSubtitle}>
|
||||
支持 MP4、AVI、MOV 等格式,最大 {maxSize}MB
|
||||
{maxCount > 1 && `,最多上传 ${maxCount} 个视频`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 自定义文件列表项
|
||||
const customItemRender = (
|
||||
originNode: React.ReactElement,
|
||||
file: UploadFile,
|
||||
) => {
|
||||
if (file.status === "uploading") {
|
||||
return (
|
||||
<div className={style.videoItem}>
|
||||
<div className={style.videoItemContent}>
|
||||
<div className={style.videoIcon}>
|
||||
<FileOutlined />
|
||||
</div>
|
||||
<div className={style.videoInfo}>
|
||||
<div className={style.videoName}>{file.name}</div>
|
||||
<div className={style.videoSize}>
|
||||
{file.size ? formatFileSize(file.size) : "计算中..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.videoActions}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
strokeColor="#1890ff"
|
||||
className={style.itemProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status === "done") {
|
||||
return (
|
||||
<div className={style.videoItem}>
|
||||
<div className={style.videoItemContent}>
|
||||
<div className={style.videoIcon}>
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
<div className={style.videoInfo}>
|
||||
<div className={style.videoName}>{file.name}</div>
|
||||
<div className={style.videoSize}>
|
||||
{file.size ? formatFileSize(file.size) : "未知大小"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.videoActions}>
|
||||
{showPreview && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(file.url || "")}
|
||||
className={style.previewBtn}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return originNode;
|
||||
};
|
||||
|
||||
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
|
||||
|
||||
return (
|
||||
<div className={`${style.videoUploadContainer} ${className || ""}`}>
|
||||
<Upload
|
||||
name="file"
|
||||
headers={{
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
}}
|
||||
action={action}
|
||||
multiple={maxCount > 1}
|
||||
fileList={fileList}
|
||||
accept="video/*"
|
||||
listType="text"
|
||||
showUploadList={{
|
||||
showPreviewIcon: false,
|
||||
showRemoveIcon: false,
|
||||
showDownloadIcon: false,
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
beforeUpload={beforeUpload}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
maxCount={maxCount}
|
||||
itemRender={customItemRender}
|
||||
>
|
||||
{fileList.length >= maxCount ? null : uploadButton}
|
||||
</Upload>
|
||||
|
||||
{/* 视频预览模态框 */}
|
||||
<Modal
|
||||
title="视频预览"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
centered
|
||||
>
|
||||
<div className={style.videoPreview}>
|
||||
<video
|
||||
controls
|
||||
style={{ width: "100%", maxHeight: "400px" }}
|
||||
src={previewUrl}
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoUpload;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 类型定义(可根据实际接口返回结构补充字段)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.messages-day-panel {
|
||||
background: #fafbfc;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
111
nkebao/src/pages/mobile/test/README.md
Normal file
111
nkebao/src/pages/mobile/test/README.md
Normal 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. 建议在移动端环境下测试以获得最佳体验
|
||||
@@ -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>
|
||||
|
||||
|
||||
354
nkebao/src/pages/mobile/test/upload.module.scss
Normal file
354
nkebao/src/pages/mobile/test/upload.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
423
nkebao/src/pages/mobile/test/upload.tsx
Normal file
423
nkebao/src/pages/mobile/test/upload.tsx
Normal 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>支持Excel、Word、PPT格式文件上传,可设置上传数量和文件类型</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;
|
||||
@@ -1,12 +0,0 @@
|
||||
import IframeDebugPage from "@/pages/iframe";
|
||||
|
||||
// iframe 调试路由
|
||||
const iframeRoutes = [
|
||||
{
|
||||
path: "/iframe",
|
||||
element: <IframeDebugPage />,
|
||||
auth: false, // 不需要认证,方便调试
|
||||
},
|
||||
];
|
||||
|
||||
export default iframeRoutes;
|
||||
@@ -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, // 不需要认证,方便调试
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user