FEAT => 本次更新项目为:完善上传组件使用说明,增加数据回显功能示例,优化代码结构,移除冗余代码

This commit is contained in:
超级老白兔
2025-08-04 15:11:27 +08:00
parent 7621b91f15
commit a4be3d534f
3 changed files with 316 additions and 255 deletions

View File

@@ -1,22 +1,28 @@
# Upload 组件使用说明
## MainImgUpload 主图封面上传组件
## 组件概述
### 功能特点
本项目提供了多个专门的上传组件,所有组件都支持编辑时的数据回显功能,确保在编辑模式下能够正确显示已上传的文件。
## 组件列表
### 1. MainImgUpload 主图封面上传组件
#### 功能特点
- 只支持上传一张图片作为主图封面
- 上传后右上角显示删除按钮
- 支持图片预览功能
- 响应式设计,适配移动端
- 样式参考VideoUpload组件风格
- 16:9宽高比宽度高度自适应
- **支持数据回显**:编辑时自动显示已上传的图片
### 使用方法
#### 使用方法
```tsx
import MainImgUpload from "@/components/Upload/MainImgUpload";
import MainImgUpload from '@/components/Upload/MainImgUpload';
const MyComponent = () => {
const [mainImage, setMainImage] = useState<string>("");
const [mainImage, setMainImage] = useState<string>('');
return (
<MainImgUpload
@@ -30,39 +36,230 @@ const MyComponent = () => {
};
```
### Props 参数
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL
const [mainImage, setMainImage] = useState<string>('https://example.com/image.jpg');
| 参数 | 类型 | 默认值 | 说明 |
| ----------- | --------------------- | ------ | ---------------- |
| value | string | '' | 当前图片URL |
| onChange | (url: string) => void | - | 图片URL变化回调 |
| disabled | boolean | false | 是否禁用 |
| className | string | - | 自定义样式类名 |
| maxSize | number | 5 | 最大文件大小(MB) |
| showPreview | boolean | true | 是否显示预览按钮 |
### 样式特点
- 上传区域200x200px 的虚线边框区域
- 图片预览:上传后显示图片,鼠标悬停显示操作按钮
- 删除按钮:右上角红色删除图标
- 预览按钮:眼睛图标,点击在新窗口预览
- 响应式:移动端自动调整尺寸
### 文件结构
```
src/components/Upload/
├── MainImgUpload.tsx # 主图上传组件
├── mainImgUpload.module.scss # 主图上传样式
├── VideoUpload.tsx # 视频上传组件
└── index.module.scss # 通用上传样式
<MainImgUpload
value={mainImage} // 会自动显示已上传的图片
onChange={setMainImage}
/>
```
### 技术实现
### 2. ImageUpload 多图上传组件
#### 功能特点
- 支持多张图片上传
- 可设置最大上传数量
- 支持图片预览和删除
- **支持数据回显**:编辑时自动显示已上传的图片数组
#### 使用方法
```tsx
import ImageUpload from '@/components/Upload/ImageUpload/ImageUpload';
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
return (
<ImageUpload
value={images}
onChange={setImages}
count={9} // 最大9张
accept="image/*"
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL数组
const [images, setImages] = useState<string[]>([
'https://example.com/image1.jpg',
'https://example.com/image2.jpg'
]);
<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={avatarUrl}
onChange={setAvatarUrl}
size={100} // 头像尺寸
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的头像URL
const [avatarUrl, setAvatarUrl] = useState<string>('https://example.com/avatar.jpg');
<AvatarUpload
value={avatarUrl} // 会自动显示已上传的头像
onChange={setAvatarUrl}
/>
```
## 数据回显机制
### 工作原理
所有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 # 使用说明文档
```
### 统一的数据回显模式
所有组件都遵循相同的数据回显模式:
```tsx
// 1. 接收value属性
interface Props {
value?: string | string[];
onChange?: (url: string | string[]) => void;
}
// 2. 使用useEffect监听value变化
useEffect(() => {
if (value) {
// 将URL转换为文件列表格式
const files = convertUrlToFileList(value);
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 3. 在UI中显示文件列表
// 4. 支持编辑、删除、预览等操作
```

View File

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

View File

@@ -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;
}
@@ -58,13 +63,6 @@ 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,
@@ -86,15 +84,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,61 +165,6 @@ 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
)
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 = "";
}
};
const items = dayPlans.map((plan, dayIndex) => ({
key: plan.day.toString(),
label: plan.day === 0 ? "即时消息" : `${plan.day}`,
@@ -404,43 +338,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 +385,81 @@ 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={1}
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}
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}
showPreview={true}
acceptTypes={["excel", "word", "ppt"]}
/>
</div>
)}
</div>
@@ -603,41 +513,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>
);
};