From 38f5fcf360ff100913bdc131be596d4f9331c41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= Date: Mon, 4 Aug 2025 11:43:08 +0800 Subject: [PATCH] =?UTF-8?q?FEAT=20=3D>=20=E6=9C=AC=E6=AC=A1=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=A1=B9=E7=9B=AE=E4=B8=BA=EF=BC=9A=20=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E7=BB=84=E4=BB=B6=E6=9E=84=E5=BB=BA=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Upload/FileUpload.module.scss | 384 +++++++++++++++ nkebao/src/components/Upload/FileUpload.tsx | 459 ++++++++++++++++++ nkebao/src/components/Upload/VideoUpload.tsx | 89 +++- nkebao/src/pages/mobile/test/upload.tsx | 197 +++++++- 4 files changed, 1099 insertions(+), 30 deletions(-) create mode 100644 nkebao/src/components/Upload/FileUpload.module.scss create mode 100644 nkebao/src/components/Upload/FileUpload.tsx diff --git a/nkebao/src/components/Upload/FileUpload.module.scss b/nkebao/src/components/Upload/FileUpload.module.scss new file mode 100644 index 00000000..29898864 --- /dev/null +++ b/nkebao/src/components/Upload/FileUpload.module.scss @@ -0,0 +1,384 @@ +// 文件上传组件样式 +.fileUploadContainer { + width: 100%; + + .fileUploadButton { + 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; + } + } + } + + .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: 40px; + height: 40px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 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: 14px; + font-weight: 500; + color: #333; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .fileSize { + font-size: 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: #f8f9fa; + border-radius: 8px; + overflow: hidden; + min-height: 500px; + + iframe { + border-radius: 8px; + background: #fff; + } + } +} + +// 动画效果 +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} + +// 响应式设计 +@media (max-width: 768px) { + .fileUploadContainer { + .fileUploadButton { + min-height: 100px; + + .uploadContent { + padding: 16px; + + .uploadIcon { + font-size: 40px; + } + + .uploadText { + .uploadTitle { + font-size: 14px; + } + + .uploadSubtitle { + font-size: 11px; + } + } + } + + .uploadingContainer { + padding: 16px; + + .uploadingIcon { + font-size: 28px; + } + + .uploadingText { + font-size: 12px; + } + } + } + + .fileItem { + padding: 10px; + + .fileItemContent { + gap: 10px; + + .fileIcon { + width: 36px; + height: 36px; + font-size: 16px; + } + + .fileInfo { + .fileName { + font-size: 13px; + } + + .fileSize { + font-size: 11px; + } + } + + .fileActions { + .previewBtn, + .deleteBtn { + padding: 3px 6px; + font-size: 12px; + } + } + } + } + + .filePreview { + min-height: 400px; + + iframe { + height: 400px; + } + } + } +} + +// 暗色主题支持 +@media (prefers-color-scheme: dark) { + .fileUploadContainer { + .fileUploadButton { + 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; + } + } + } + } + + .fileItem { + background: #2a2a2a; + border-color: #434343; + + &:hover { + border-color: #40a9ff; + } + + .fileItemContent { + .fileInfo { + .fileName { + color: #fff; + } + + .fileSize { + color: #ccc; + } + } + + .fileActions { + .previewBtn, + .deleteBtn { + &:hover { + background: #434343; + } + } + } + } + } + + .filePreview { + background: #2a2a2a; + + iframe { + background: #1f1f1f; + } + } + } +} diff --git a/nkebao/src/components/Upload/FileUpload.tsx b/nkebao/src/components/Upload/FileUpload.tsx new file mode 100644 index 00000000..a262cf7f --- /dev/null +++ b/nkebao/src/components/Upload/FileUpload.tsx @@ -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 "./FileUpload.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 = ({ + value = "", + onChange, + disabled = false, + className, + maxSize = 10, + showPreview = true, + maxCount = 1, + acceptTypes = ["excel", "word", "ppt"], +}) => { + const [loading, setLoading] = useState(false); + const [fileList, setFileList] = useState([]); + 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 = ( +
+ {loading ? ( +
+
+ +
+
上传中...
+ +
+ ) : ( +
+
+ +
+
+
+ {maxCount === 1 + ? "上传文档" + : `上传文档 (${fileList.length}/${maxCount})`} +
+
+ 支持{" "} + {acceptTypes + .map( + type => + fileTypeConfig[type as keyof typeof fileTypeConfig]?.name, + ) + .filter(Boolean) + .join("、")} + ,最大 {maxSize}MB + {maxCount > 1 && `,最多上传 ${maxCount} 个文件`} +
+
+
+ )} +
+ ); + + // 自定义文件列表项 + const customItemRender = ( + originNode: React.ReactElement, + file: UploadFile, + ) => { + const FileIcon = file.originFileObj + ? getFileIcon(file.originFileObj) + : FileOutlined; + + if (file.status === "uploading") { + return ( +
+
+
+ +
+
+
{file.name}
+
+ {file.size ? formatFileSize(file.size) : "计算中..."} +
+
+
+
+
+ +
+ ); + } + + if (file.status === "done") { + return ( +
+
+
+ +
+
+
{file.name}
+
+ {file.size ? formatFileSize(file.size) : "未知大小"} +
+
+
+ {showPreview && ( +
+
+
+ ); + } + + return originNode; + }; + + const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload"; + + return ( +
+ 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} + + + {/* 文件预览模态框 */} + setPreviewVisible(false)} + footer={null} + width={800} + centered + > +
+