diff --git a/nkebao/src/components/Upload/README.md b/nkebao/src/components/Upload/README.md index 9cfee0ec..05c1a327 100644 --- a/nkebao/src/components/Upload/README.md +++ b/nkebao/src/components/Upload/README.md @@ -1,150 +1,112 @@ -# Upload 组件 +# Upload 上传组件 -通用的文件上传组件,基于 Ant Design 的 Upload 组件封装。 +基于 antd-mobile 的 ImageUploader 组件封装的上传组件,支持图片上传、预览、删除等功能。 -## 组件列表 +## 功能特性 -### UploadComponent +- ✅ 支持单张/多张图片上传 +- ✅ 文件类型和大小验证 +- ✅ 上传进度显示 +- ✅ 图片预览功能 +- ✅ 删除确认 +- ✅ 数量限制 +- ✅ 编辑和新增状态支持 +- ✅ 响应式设计 -通用的图片/文件上传组件,支持多文件上传。 +## 使用方法 -#### 使用方法 +### 基础用法 ```tsx -import UploadComponent from '@/components/Upload'; +import React, { useState } from "react"; +import UploadComponent from "@/components/Upload"; -// 基础用法 - +const MyComponent = () => { + const [images, setImages] = useState([]); -// 单文件上传 - setSingleImage(urls[0])} - count={1} - listType="picture-card" -/> + return ( + + ); +}; ``` -#### Props - -| 参数 | 说明 | 类型 | 默认值 | -|------|------|------|--------| -| value | 已上传的文件URL数组 | `string[]` | `[]` | -| onChange | 文件列表变化时的回调 | `(urls: string[]) => void` | - | -| count | 最大上传数量 | `number` | `9` | -| accept | 接受的文件类型 | `string` | `"image/*"` | -| listType | 上传列表的内建样式 | `"text" \| "picture" \| "picture-card"` | `"picture-card"` | -| disabled | 是否禁用 | `boolean` | `false` | -| className | 自定义样式类名 | `string` | - | - -### VideoUpload - -专门的视频上传组件,支持单文件上传。 - -#### 使用方法 +### 编辑模式 ```tsx -import VideoUpload from '@/components/Upload/VideoUpload'; +const EditComponent = () => { + const [images, setImages] = useState([ + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + ]); - + return ( + + ); +}; ``` -#### Props +### 禁用状态 -| 参数 | 说明 | 类型 | 默认值 | -|------|------|------|--------| -| value | 已上传的视频URL | `string` | `""` | -| onChange | 视频URL变化时的回调 | `(url: string) => void` | - | -| disabled | 是否禁用 | `boolean` | `false` | -| className | 自定义样式类名 | `string` | - | - -## 技术实现 - -### 上传接口 - -- **接口地址**: `/v1/attachment/upload` -- **请求方式**: `POST` -- **请求格式**: `multipart/form-data` -- **文件字段名**: `file` - -### 响应格式 - -组件会自动处理以下响应格式: - -```json -{ - "code": 200, - "data": "https://example.com/file.jpg", - "message": "success" -} +```tsx + ``` -或者: +## API -```json -{ - "code": 200, - "data": { - "url": "https://example.com/file.jpg" - }, - "message": "success" -} -``` +### Props -### 文件限制 +| 参数 | 说明 | 类型 | 默认值 | +| --------- | -------------- | -------------------------- | ----------- | +| value | 图片URL数组 | `string[]` | `[]` | +| onChange | 图片变化回调 | `(urls: string[]) => void` | - | +| count | 最大上传数量 | `number` | `9` | +| accept | 接受的文件类型 | `string` | `"image/*"` | +| disabled | 是否禁用 | `boolean` | `false` | +| className | 自定义类名 | `string` | - | -- **图片文件**: 最大 5MB -- **视频文件**: 最大 50MB -- **支持格式**: 根据 `accept` 属性设置 +### 事件 -### 状态管理 - -组件内部管理以下状态: - -- `uploading`: 文件上传中 -- `done`: 文件上传完成 -- `error`: 文件上传失败 -- `removed`: 文件已删除 +| 事件名 | 说明 | 回调参数 | +| -------- | ------------------ | -------------------------- | +| onChange | 图片列表变化时触发 | `(urls: string[]) => void` | ## 注意事项 -1. **API 集成**: 组件使用 Ant Design Upload 的 `action` 属性,直接调用 `/v1/attachment/upload` 接口 -2. **文件验证**: 在 `beforeUpload` 中进行文件类型和大小验证 -3. **状态同步**: 组件会自动同步 `value` 和内部 `fileList` 状态 -4. **错误处理**: 上传失败时会显示错误提示并自动清理失败的文件 -5. **进度显示**: 上传过程中会显示加载状态和进度指示 +1. **文件大小限制**: 默认限制为 5MB +2. **文件类型**: 默认只接受图片文件 +3. **上传接口**: 使用 `/v1/attachment/upload` 接口 +4. **认证**: 自动携带 token 进行认证 +5. **预览**: 点击图片可预览 +6. **删除**: 删除图片会有确认提示 ## 样式定制 -组件使用 SCSS Modules,可以通过修改 `index.module.scss` 文件来自定义样式: +组件支持通过 CSS 模块进行样式定制: ```scss -.upload-container { - // 容器样式 -} - -.upload-button { - // 上传按钮样式 -} - -.upload-icon { - // 图标样式 -} - -.upload-text { - // 文字样式 -} - -.uploading { - // 上传中状态样式 +.uploadContainer { + // 自定义样式 + :global { + .adm-image-uploader { + // 覆盖 antd-mobile 默认样式 + } + } } ``` + +## 错误处理 + +- 文件类型不匹配时会显示错误提示 +- 文件大小超限时会显示错误提示 +- 上传失败时会显示错误提示 +- 网络错误时会显示错误提示 diff --git a/nkebao/src/components/Upload/index.module.scss b/nkebao/src/components/Upload/index.module.scss index 54a4c006..beab0535 100644 --- a/nkebao/src/components/Upload/index.module.scss +++ b/nkebao/src/components/Upload/index.module.scss @@ -1,75 +1,108 @@ -.upload-container { +.uploadContainer { width: 100%; -} -.upload-button { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100px; - border: 1px dashed #d9d9d9; - border-radius: 6px; - background: #fafafa; - cursor: pointer; - transition: all 0.3s; + // 自定义上传组件样式 + :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; - } -} + &:hover { + border-color: #1677ff; + background: #f0f8ff; + } -.upload-icon { - font-size: 24px; - color: #999; - margin-bottom: 8px; -} + .adm-image-uploader-upload-button-icon { + font-size: 32px; + color: #999; + } + } -.upload-text { - font-size: 14px; - color: #666; -} + .adm-image-uploader-item { + width: 100px; + height: 100px; + border-radius: 8px; + overflow: hidden; + position: relative; -.uploading { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; + .adm-image-uploader-item-image { + width: 100%; + height: 100%; + object-fit: cover; + } - .upload-icon { - color: #1677ff; - animation: spin 1s linear infinite; - } + .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; + } - .upload-text { - color: #1677ff; - } -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -// 覆盖antd默认样式 -:global { - .ant-upload-list-picture-card { - .ant-upload-list-item { - width: 100px; - height: 100px; + .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; + } + } } } +} - .ant-upload-select-picture-card { - width: 100px; - height: 100px; +// 禁用状态 +.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; + } + } + } } } diff --git a/nkebao/src/components/Upload/index.tsx b/nkebao/src/components/Upload/index.tsx index 23a233b9..73ec84a9 100644 --- a/nkebao/src/components/Upload/index.tsx +++ b/nkebao/src/components/Upload/index.tsx @@ -1,7 +1,6 @@ -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 React, { useState, useEffect } from "react"; +import { ImageUploader, Toast, Dialog } from "antd-mobile"; +import type { ImageUploadItem } from "antd-mobile/es/components/image-uploader"; import style from "./index.module.scss"; interface UploadComponentProps { @@ -9,7 +8,6 @@ interface UploadComponentProps { onChange?: (urls: string[]) => void; count?: number; // 最大上传数量 accept?: string; // 文件类型 - listType?: "text" | "picture" | "picture-card"; disabled?: boolean; className?: string; } @@ -19,21 +17,17 @@ const UploadComponent: React.FC = ({ onChange, count = 9, accept = "image/*", - listType = "picture-card", disabled = false, className, }) => { - const [loading, setLoading] = useState(false); - const [fileList, setFileList] = useState([]); + const [fileList, setFileList] = useState([]); // 将value转换为fileList格式 - React.useEffect(() => { + useEffect(() => { if (value && value.length > 0) { const files = value.map((url, index) => ({ + url: url || "", uid: `file-${index}`, - name: `file-${index}`, - status: "done" as const, - url: url || "", // 确保 URL 不为 undefined })); setFileList(files); } else { @@ -43,89 +37,97 @@ const UploadComponent: React.FC = ({ // 文件验证 const beforeUpload = (file: File) => { + // 检查文件类型 const isValidType = file.type.startsWith(accept.replace("*", "")); if (!isValidType) { - message.error(`只能上传${accept}格式的文件!`); - return false; + Toast.show(`只能上传${accept}格式的文件!`); + return null; } + // 检查文件大小 (5MB) const isLt5M = file.size / 1024 / 1024 < 5; if (!isLt5M) { - message.error("文件大小不能超过5MB!"); - return false; + Toast.show("文件大小不能超过5MB!"); + return null; } - return true; // 允许上传 + return file; + }; + + // 上传函数 + const upload = async (file: File): Promise<{ url: string }> => { + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch( + `${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`, + { + method: "POST", + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + body: formData, + } + ); + + if (!response.ok) { + throw new Error("上传失败"); + } + + const result = await response.json(); + + if (result.code === 200) { + Toast.show("上传成功"); + return { url: result.data.url || result.data }; + } else { + throw new Error(result.msg || "上传失败"); + } + } catch (error) { + Toast.show("上传失败,请重试"); + throw error; + } }; // 处理文件变化 - const handleChange: UploadProps["onChange"] = info => { - // 更新 fileList,确保所有 URL 都是字符串 - const updatedFileList = info.fileList.map(file => ({ - ...file, - url: - file.url || - file.response?.data || - file.response?.url || - file.response || - "", - })); + const handleChange = (files: ImageUploadItem[]) => { + setFileList(files); - setFileList(updatedFileList); + // 提取URL数组并传递给父组件 + const urls = files + .map(file => file.url) + .filter(url => Boolean(url)) as string[]; - // 处理上传状态 - if (info.file.status === "uploading") { - setLoading(true); - } else if (info.file.status === "done") { - setLoading(false); - message.success("上传成功"); - console.log(info.file.response); - // 从响应中获取上传后的URL - const uploadedUrl = info.file.response?.data?.url || ""; - if (uploadedUrl) { - onChange?.([uploadedUrl]); - } - } else if (info.file.status === "error") { - setLoading(false); - message.error("上传失败,请重试"); - } else if (info.file.status === "removed") { - // 文件被删除 - const urls = updatedFileList - .map(f => f.url || "") - .filter(Boolean) as string[]; - onChange?.(urls); - } - }; - - // 删除文件 - const handleRemove = (file: UploadFile) => { - const newFileList = fileList.filter(f => f.uid !== file.uid); - setFileList(newFileList); - const urls = newFileList.map(f => f.url || "").filter(Boolean) as string[]; onChange?.(urls); - return true; }; - const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload"; + // 删除确认 + const handleDelete = () => { + return Dialog.confirm({ + content: "确定要删除这张图片吗?", + }); + }; + + // 数量超出限制 + const handleCountExceed = (exceed: number) => { + Toast.show(`最多选择 ${count} 张图片,你多选了 ${exceed} 张`); + }; return ( - 1} - fileList={fileList} - accept={accept} - listType={listType} - showUploadList={true} - disabled={disabled || loading} - beforeUpload={beforeUpload} - onChange={handleChange} - onRemove={handleRemove} - maxCount={count} - > + + 1} + maxCount={count} + showUpload={fileList.length < count && !disabled} + accept={accept} + /> + ); }; diff --git a/nkebao/src/pages/mobile/content/materials/form/index.tsx b/nkebao/src/pages/mobile/content/materials/form/index.tsx index 6987d8d0..2e50c9b4 100644 --- a/nkebao/src/pages/mobile/content/materials/form/index.tsx +++ b/nkebao/src/pages/mobile/content/materials/form/index.tsx @@ -302,17 +302,30 @@ const MaterialForm: React.FC = () => { {/* 素材上传(仅图片类型和小程序类型) */} {[1, 5].includes(contentType) && ( - 素材上传 + + 素材上传 (当前类型: {contentType}) + {contentType === 1 && ( 图片上传 - + + + + + 当前内容类型: {contentType}, 图片数量: {resUrls.length} + )}