到腾讯云官网开通对象存储、获取 api 密钥,等一系列步骤略过
腾讯云官网文档:https://cloud.tencent.com/document/product/436/65935#0a5a6b09-0777-4d51-a090-95565985fe2c
1. 后端
1.1. 引入依赖
pom.xml
java
<!-- 腾讯云对象存储 -->
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.227</version>
</dependency>
1.2. 初始化对象存储客户端
CosClientConfig
java
package com.yu.cloudpicturebackend.config;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.auth.COSCredentials;
import com.qcloud.cos.http.HttpProtocol;
import com.qcloud.cos.region.Region;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "cos.client")
@Data
public class CosClientConfig {
/**
* 域名
*/
private String host;
/**
* secretId
*/
private String secretId;
/**
* 密钥 (注意不要泄露)
*/
private String secretKey;
/**
* 桶名
*/
private String bucket;
/**
* 区域
*/
private String region;
@Bean
public COSClient cosClient() {
// 1 初始化用户身份信息(secretId, secretKey)。
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
// 2 设置 bucket 的地域, COS 地域的简称请参见 https://cloud.tencent.com/document/product/436/6224
ClientConfig clientConfig = new ClientConfig(new Region(region));
// 这里建议设置使用 https 协议
// 从 5.6.54 版本开始,默认使用了 https
clientConfig.setHttpProtocol(HttpProtocol.https);
// 3 生成 cos 客户端。
return new COSClient(cred, clientConfig);
}
}
1.3. 对象存储配置
application-local.yml
java
# 对象存储配置
cos:
client:
host: xxxxxxxxxxxxxxxxxxxxxx.cos.ap-nanjing.myqcloud.com
secretId: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
secretKey: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
region: ap-nanjing
bucket: xxxxxxxxxxxxxxxx
主配置文件中启用本地配置:
application.yml
XML
server:
port: 8123
spring:
profiles:
active: local
1.4. 对象存储管理器
CosManager
java
package com.yu.cloudpicturebackend.manager;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.exception.CosClientException;
import com.qcloud.cos.exception.CosServiceException;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.model.PutObjectResult;
import com.yu.cloudpicturebackend.config.CosClientConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.io.File;
/**
* 对象存储管理器-封装通用的对象存储方法
*/
@Component
@Slf4j
public class CosManager {
@Resource
private CosClientConfig cosClientConfig;
@Resource
private COSClient cosClient;
// 将本地文件上传到 COS
public PutObjectResult putObject(String key, File file) {
PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file);
return cosClient.putObject(putObjectRequest);
}
}
1.5. 上传图片测试接口
java
/**
* 测试文件上传
*
* @param multipartFile
* @return
*/
@PostMapping("/test/upload")
public BaseResponse<String> testUploadFile(@RequestPart("file") MultipartFile multipartFile) {
String filename = multipartFile.getOriginalFilename();
String filePath = String.format("/test/%s", filename);
File file = null;
try {
// 创建空的临时文件
file = File.createTempFile(filePath, null);
// 将上传文件内容传输到临时文件
multipartFile.transferTo(file);
// 上传到COS(可能是腾讯云对象存储)
cosManager.putObject(filePath, file);
// 返回可访问地址
return ResultUtils.success(filePath);
} catch (Exception e) {
log.error("file upload error,filePath" + filePath, e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
} finally {
if (file != null) {
// 删除临时文件
boolean delete = file.delete();
if (!delete) {
log.error("file delete error,filePath={}", filePath);
}
}
}
}
1.6. 业务代码
FilePictureUpload
java
package com.yu.cloudpicturebackend.manager.upload;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.RandomUtil;
import com.yu.cloudpicturebackend.config.CosClientConfig;
import com.yu.cloudpicturebackend.exception.BusinessException;
import com.yu.cloudpicturebackend.exception.ErrorCode;
import com.yu.cloudpicturebackend.exception.ThrowUtils;
import com.yu.cloudpicturebackend.manager.CosManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.File;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
/**
* 图片上传服务
*/
@Service
@Slf4j
public class FilePictureUpload {
@Resource
private CosClientConfig cosClientConfig;
@Resource
private CosManager cosManager;
/**
* 上传图片
*
* @param multipartFile 文件
* @param uploadPathPrefix 上传路径前缀
* @return
*/
public String uploadAvatar(MultipartFile multipartFile, String uploadPathPrefix) {
// 校验文件
validatePicture(multipartFile);
// 源文件名
String uuid = RandomUtil.randomString(12);
String originalFilename = multipartFile.getOriginalFilename();
// 上传到对象存储的文件名-文件格式:日期_uuid.文件后缀
String uploadFileName = String.format("%s_%s.%s", DateUtil.formatDate(new Date()),
uuid, FileUtil.getSuffix(originalFilename));
// 拼接文件上传路径,而不是使用原始文件名,可以增强安全性
String uploadPath = String.format("/%s/%s", uploadPathPrefix, uploadFileName);
// 上传文件
File file = null;
try {
// 创建空的临时文件
file = File.createTempFile(uploadPath, null);
// 将上传文件内容传输到临时文件
multipartFile.transferTo(file);
// 上传到 cos
cosManager.putObject(uploadPath, file);
// 封装返回结果
return cosClientConfig.getHost() + uploadPath;
} catch (Exception e) {
log.error("图片上传到对象存储失败", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
} finally {
// 清理临时文件
deleteTempFile(file);
}
}
// 校验图片文件
private void validatePicture(MultipartFile multipartFile) {
ThrowUtils.throwIf(multipartFile == null, ErrorCode.PARAMS_ERROR, "文件不能为空");
// 校验图片文件大小
final long ONE_M = 1024 * 1024;
ThrowUtils.throwIf(multipartFile.getSize() > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2MB");
// 校验文件后缀
String suffix = FileUtil.getSuffix(multipartFile.getOriginalFilename());
final List<String> ALLOW_FILE_FORMAT = Arrays.asList("png", "jpg", "jpeg", "gif", "webp");
ThrowUtils.throwIf(!ALLOW_FILE_FORMAT.contains(suffix), ErrorCode.PARAMS_ERROR, "文件类型错误");
}
// 清理临时文件
public void deleteTempFile(File file) {
if (file == null) {
return;
}
boolean isDelete = file.delete();
if (!isDelete) {
log.error("临时文件删除失败,文件路径: {}", file.getAbsolutePath());
}
}
}
UserController
java
/**
* @author lianyu
* @date 2025-09-28 19:35:01
*/
@RestController
@RequestMapping("/user")
@Api(tags = "用户接口")
@Slf4j
public class UserController {
@Resource
private CosManager cosManager;
@Resource
private FilePictureUpload filePictureUpload;
/**
* 上传头像
*
* @param multipartFile 文件
* @return
*/
@PostMapping("/avatar/upload")
@ApiOperation("上传用户头像")
public BaseResponse<Boolean> uploadAvatar(@RequestParam Long id,
@RequestPart("file") MultipartFile multipartFile) {
ThrowUtils.throwIf(!saTokenUtils.isLogin(), NOT_LOGIN_ERROR);
ThrowUtils.throwIf(id == null, PARAMS_ERROR, "用户 id 不能为空");
LoginUserVO loginUser = saTokenUtils.getLoginUser();
String userRole = loginUser.getUserRole();
// 不是管理员,并且修改的用户头像不是自己的,则无权限
if (!userRole.equals(UserRoleEnum.ADMIN.getValue()) && !id.equals(loginUser.getId())) {
throw new BusinessException(NO_AUTH_ERROR);
}
String avatarUrl = filePictureUpload.uploadAvatar(multipartFile, "user");
UpdateUserRequest updateUserRequest = new UpdateUserRequest();
updateUserRequest.setId(id);
updateUserRequest.setUserAvatar(avatarUrl);
UpdateWrapper<User> updateWrapper = userService.getUpdateWrapper(updateUserRequest);
boolean isUpdated = userService.update(updateWrapper);
ThrowUtils.throwIf(!isUpdated, OPERATION_ERROR);
return ResultUtils.success(true);
}
}
1.7. 配置上传文件大小上限
application.yml
XML
spring:
servlet:
multipart:
enabled: true
max-file-size: 2MB # 单个文件大小限制
max-request-size: 20MB # 单次请求总大小限制
2. 前端
2.1. UserManagePage.vue - 父页面
html
<template>
<div class="user-manage-page">
<a-table
:columns="columns"
:data-source="dataList"
:pagination="pagination"
:loading="loading"
:scroll="{ x: 1400, y: 460 }"
@change="doTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'userAvatar'">
<div class="avatar-box">
<div class="avatar-mask" @click="handleUploadAvatarModal(record.id, record.userAvatar)"></div>
<a-avatar
:size="52"
:src="record.userAvatar ? record.userAvatar : '/src/assets/avatar.png'"
></a-avatar>
</div>
</template>
</template>
</a-table>
<UploadAvatarModal
ref="uploadAvatarModalRef"
:onSuccess="
async () => {
await fetchData()
}
"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, type UnwrapRef } from 'vue'
import {
updateUserUsingPost
} from '@/api/yonghujiekou.ts'
import { message } from 'ant-design-vue'
const columns = [
{
title: 'id',
dataIndex: 'id',
width: '170px',
},
{
title: '用户名',
dataIndex: 'userName',
width: '140px',
},
{
title: '邮箱',
dataIndex: 'email',
width: '140px',
},
{
title: '头像',
dataIndex: 'userAvatar',
width: '90px',
},
{
title: '角色',
dataIndex: 'userRole',
width: '150px',
},
{
title: '手机号',
dataIndex: 'mobile',
width: '150px',
},
{
title: '创建时间',
dataIndex: 'createTime',
},
{
title: '编辑时间',
dataIndex: 'editTime',
},
{
title: '操作',
key: 'action',
width: '180px',
},
]
const dataList = ref<API.UserVO[]>([])
const cancel = (key: string) => {
delete editableData[key]
}
const uploadAvatarModalRef = ref()
// 打开更换头像对话框
const handleUploadAvatarModal = (id: string, avatarUrl: string) => {
if (uploadAvatarModalRef.value) {
uploadAvatarModalRef.value.openModal(id, avatarUrl)
}
}
const loading = ref<boolean>(false)
import UploadAvatarModal from '@/components/UploadAvatarModal.vue'
// 获取数据
const fetchData = async () => {
loading.value = true
const res = await listUserVoUsingPost({
...searchParams,
})
if (res.data.code == 0 && res.data.data) {
dataList.value = res.data.data.records ?? []
total.value = res.data.data.total ?? 0
loading.value = false
} else {
message.error(res.data.message)
}
}
onMounted(async () => {
await fetchData()
})
</script>
<style scoped>
.user-manage-page {
box-sizing: border-box;
.avatar-box {
width: 52px;
height: 52px;
position: relative;
.avatar-mask {
position: absolute;
width: 52px;
height: 52px;
border-radius: 50%;
z-index: 1;
text-align: center;
line-height: 52px;
color: #fff;
background-color: rgba(128, 128, 128, 0);
transition: all 0.5s ease;
}
.avatar-mask:hover {
cursor: pointer;
background-color: rgba(128, 128, 128, 0.8);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
&:before {
content: '更换';
}
}
}
}
</style>
2.2. UploadAvatarModal.vue - 图片预览 + 手动上传
实现图片预览,两种方式
- 使用 base64 预览图片
- 使用 Object URL预览图片
对比:
|------|-------------|------------|
| 特性 | Base64 | Object URL |
| 性能 | 较差,需要编码整个文件 | 较好,直接引用文件 |
| 内存占用 | 较高,字符串形式存储 | 较低,引用形式 |
| 使用难度 | 简单 | 简单 |
| 内存管理 | 自动回收 | 需要手动释放 |
| 兼容性 | 很好 | 很好 |
2.2.1. base64 预览图片
html
<template>
<div>
<a-upload
v-model:file-list="fileList"
name="avatar"
list-type="picture-card"
class="avatar-uploader"
:show-upload-list="true"
:before-upload="beforeUpload"
@preview="handlePreview"
>
<div v-if="fileList.length === 0">
<loading-outlined v-if="loading"></loading-outlined>
<plus-outlined v-else></plus-outlined>
<div class="ant-upload-text">Upload</div>
</div>
</a-upload>
<a-modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
@cancel="handleCancelPreview"
>
<img alt="image" style="width: 100%" :src="previewImage" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const fileList = ref([])
const loading = ref<boolean>(false)
const previewVisible = ref(false)
const previewImage = ref('')
const previewTitle = ref('')
const props = defineProps<Props>()
// 这个函数就是将文件转换为 Base64 用于预览
function getBase64(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
// 在预览时使用
const handlePreview = async (file: UploadProps['fileList'][number]) => {
if (!file.url && !file.preview) {
file.preview = (await getBase64(file.originFileObj)) as string
}
previewImage.value = file.url || file.preview || ''
previewVisible.value = true
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1)
}
const handleCancel = () => {
open.value = false
fileList.value = []
}
const openModal = (id: string) => {
formState.value.id = id
open.value = true
}
defineExpose({ openModal })
</script>
<style scoped></style>
2.2.2. Object URL预览图片
html
<template>
<div>
<a-upload
v-model:file-list="fileList"
name="avatar"
list-type="picture-card"
class="avatar-uploader"
:show-upload-list="true"
:before-upload="beforeUpload"
@remove="handleRemove"
@preview="handlePreview"
>
<div v-if="fileList.length === 0">
<loading-outlined v-if="loading"></loading-outlined>
<plus-outlined v-else></plus-outlined>
<div class="ant-upload-text">Upload</div>
</div>
</a-upload>
<a-modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
@cancel="handleCancelPreview"
>
<img alt="image" style="width: 100%" :src="previewImage" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const fileList = ref([])
const loading = ref<boolean>(false)
const previewVisible = ref(false)
const previewImage = ref('')
const previewTitle = ref('')
// 在预览时使用
const handlePreview = async (file: UploadProps['fileList'][number]) => {
console.log('文件对象:', file) // 先打印看看文件结构
let previewUrl = file.url || file.preview
if (!previewUrl && file.originFileObj) {
// 如果有 originFileObj,使用它,创建对象URL - 性能更好,不占用内存
previewUrl = URL.createObjectURL(file.originFileObj)
} else if (!previewUrl && file instanceof File) {
// 如果 file 本身就是 File 对象
previewUrl = URL.createObjectURL(file)
} else if (!previewUrl && file) {
// 尝试直接使用 file
try {
previewUrl = URL.createObjectURL(file)
} catch (error) {
console.error('创建预览URL失败:', error)
}
}
previewImage.value = file.url || previewUrl || ''
previewVisible.value = true
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1)
}
const handleCancel = () => {
open.value = false
fileList.value = []
}
// 记得在组件卸载时释放URL
const handleCancelPreview = () => {
// 释放对象URL,避免内存泄漏
if (previewImage.value.startsWith('blob:')) {
URL.revokeObjectURL(previewImage.value)
}
previewVisible.value = false
}
const openModal = (id: string) => {
formState.value.id = id
open.value = true
}
defineExpose({ openModal })
</script>
<style scoped></style>
2.2.3. 子组件的头像回显
父页面中已经传给子组件对应的用户头像,子组件中将头像地址设置到文件列表内中即可回显
javascript
<script setup lang="ts">
// ... 其他代码保持不变
const openModal = (id: string, avatar: string) => {
formState.value.id = id
originalAvatar.value = avatar
// 将原头像添加到文件列表中显示
if (avatar) {
fileList.value = [{
uid: '-1', // 使用负值避免冲突
name: '原头像',
status: 'done',
url: avatar,
thumbUrl: avatar
}]
} else {
fileList.value = []
}
open.value = true
}
// 修改 beforeUpload 方法,保留原头像的显示
const beforeUpload = (file: UploadProps['fileList'][number]) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/gif']
const isAllowed = allowedTypes.some((type) => file.type.includes(type))
if (!isAllowed) {
message.error('图片格式错误!')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过2MB')
return false
}
// 添加上传的文件,保留原头像信息
fileList.value = [
...fileList.value.filter(item => item.uid === '-1'), // 保留原头像
file // 添加新文件
]
return false
}
</script>
2.2.4. 手动上传
antdv 的 upload 组件支持手动上传,需要在 beforeUpload 中返回 false,阻止自动上传
javascript
// 上传文件前的逻辑
const beforeUpload = (file: UploadProps['fileList'][number]) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/gif']
const isAllowed = allowedTypes.some((type) => file.type.includes(type))
if (!isAllowed) {
message.error('图片格式错误!')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过2MB')
return false
}
// 手动添加文件到列表
fileList.value = [file] // 如果是单文件上传,只保留一个文件
return false // 返回 false 阻止自动上传
}
2.2.5. 调用上传接口
接口(由 openapi 自动生成)
javascript
/** 上传用户头像 POST /api/user/avatar/upload */
export async function uploadAvatarUsingPost(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.uploadAvatarUsingPOSTParams,
body: {},
file?: File,
options?: { [key: string]: any }
) {
const formData = new FormData()
if (file) {
formData.append('file', file)
}
Object.keys(body).forEach((ele) => {
const item = (body as any)[ele]
if (item !== undefined && item !== null) {
if (typeof item === 'object' && !(item instanceof File)) {
if (item instanceof Array) {
item.forEach((f) => formData.append(ele, f || ''))
} else {
formData.append(ele, new Blob([JSON.stringify(item)], { type: 'application/json' }))
}
} else {
formData.append(ele, item)
}
}
})
return request<API.BaseResponseBoolean_>('/api/user/avatar/upload', {
method: 'POST',
params: {
...params,
},
data: formData,
requestType: 'form',
...(options || {}),
})
}
方案1:直接传递源文件上传文件
// 上传文件
const handleUpload = async () => {
if (fileList.value.length === 0) {
message.error('请选择图片')
return
}
const file = fileList.value[0]
// 调试信息
console.log('上传文件信息:', {
file: file,
originFileObj: file.originFileObj,
isFile: file instanceof File,
id: formState.value.id
})
loading.value = true
try {
// 方式1:直接传递文件对象(推荐)
const res = await uploadAvatarUsingPost(
{ id: formState.value.id }, // 第一个参数:URL参数
{}, // 第二个参数:body参数
file.originFileObj || file // 第三个参数:文件对象
)
if (res.data.code === 0) {
message.success('更换成功')
handleCancel()
props.onSuccess?.()
} else {
message.error(res.data.message || '上传失败')
}
} catch (error: any) {
console.error('上传错误详情:', error)
message.error(error.response?.data?.message || '上传失败,请重试')
} finally {
loading.value = false
}
}
方案2:使用 FormData 上传文件
// 上传文件 - FormData方式
const handleUpload = async () => {
if (fileList.value.length === 0) {
message.error('请选择图片')
return
}
const file = fileList.value[0]
const fileObj = file.originFileObj || file
// 创建 FormData
const formData = new FormData()
formData.append('file', fileObj)
// 如果有其他参数,也添加到 FormData
if (formState.value.id) {
formData.append('id', formState.value.id)
}
loading.value = true
try {
const res = await uploadAvatarUsingPost(
{}, // URL参数为空
formData, // 第二个参数:FormData
// 不传第三个文件参数,因为文件已经在 FormData 里了
)
if (res.data.code === 0) {
message.success('更换成功')
handleCancel()
props.onSuccess?.()
} else {
message.error(res.data.message || '上传失败')
}
} catch (error: any) {
console.error('上传错误详情:', error)
message.error(error.response?.data?.message || '上传失败,请重试')
} finally {
loading.value = false
}
}
2.2.6. 完整代码
html
<template>
<div class="reset-password-modal">
<a-modal
title="更换头像"
:open="open"
@ok="handleUpload"
@cancel="handleCancel"
ok-text="确定"
cancel-text="取消"
:ok-button-props="{ disabled: fileList.length === 0 }"
style="width: 200px; min-width: 300px"
:bodyStyle="{ textAlign: 'center' }"
>
<a-upload
v-model:file-list="fileList"
name="avatar"
list-type="picture-card"
class="avatar-uploader"
:show-upload-list="true"
:before-upload="beforeUpload"
@remove="handleRemove"
@preview="handlePreview"
>
<div v-if="fileList.length === 0">
<loading-outlined v-if="loading"></loading-outlined>
<plus-outlined v-else></plus-outlined>
<div class="ant-upload-text">Upload</div>
</div>
</a-upload>
</a-modal>
<a-modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
@cancel="handleCancelPreview"
>
<img alt="image" style="width: 100%" :src="previewImage" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { uploadAvatarUsingPost } from '@/api/yonghujiekou.ts'
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import type { UploadProps } from 'ant-design-vue'
import { useLoginUserStore } from '@/stores/loginUserStore.ts'
const open = ref(false)
interface Props {
onSuccess?: () => void
}
const formState = ref<API.ResetPasswordRequest>({
password: '',
})
const fileList = ref([])
const loading = ref<boolean>(false)
const previewVisible = ref(false)
const previewImage = ref('')
const previewTitle = ref('')
const props = defineProps<Props>()
const handleRemove: UploadProps['onRemove'] = (file) => {
const index = fileList.value.indexOf(file)
const newFileList = fileList.value.slice()
newFileList.splice(index, 1)
fileList.value = newFileList
}
// 这个函数就是将文件转换为 Base64 用于预览
// function getBase64(file: File) {
// return new Promise((resolve, reject) => {
// const reader = new FileReader()
// reader.readAsDataURL(file)
// reader.onload = () => resolve(reader.result)
// reader.onerror = (error) => reject(error)
// })
// }
// 在预览时使用
const handlePreview = async (file: UploadProps['fileList'][number]) => {
// if (!file.url && !file.preview) {
// file.preview = (await getBase64(file.originFileObj)) as string
// }
console.log('文件对象:', file) // 先打印看看文件结构
let previewUrl = file.url || file.preview
if (!previewUrl && file.originFileObj) {
// 如果有 originFileObj,使用它,创建对象URL - 性能更好,不占用内存
previewUrl = URL.createObjectURL(file.originFileObj)
} else if (!previewUrl && file instanceof File) {
// 如果 file 本身就是 File 对象
previewUrl = URL.createObjectURL(file)
} else if (!previewUrl && file) {
// 尝试直接使用 file
try {
previewUrl = URL.createObjectURL(file)
} catch (error) {
console.error('创建预览URL失败:', error)
}
}
previewImage.value = file.url || previewUrl || ''
previewVisible.value = true
previewTitle.value = file.name || file.url.substring(file.url.lastIndexOf('/') + 1)
}
// 上传文件前的逻辑
const beforeUpload = (file: UploadProps['fileList'][number]) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/gif']
const isAllowed = allowedTypes.some((type) => file.type.includes(type))
if (!isAllowed) {
message.error('图片格式错误!')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过2MB')
return false
}
// 手动添加文件到列表
fileList.value = [
...fileList.value.filter((item) => item.uid === '-1'), // 保留原头像
file,
] // 如果是单文件上传,只保留一个文件
return false // 返回 false 阻止自动上传
}
const loginUserStore = useLoginUserStore()
// 上传文件
const handleUpload = async () => {
if (fileList.value.length === 0) {
message.error('请选择图片')
return
}
const file = fileList.value[0]
try {
// 方式1:直接传递文件对象(推荐)
const res = await uploadAvatarUsingPost(
{ id: formState.value.id }, // 第一个参数:URL参数
{}, // 第二个参数:body参数
file.originFileObj || file, // 第三个参数:文件对象
)
if (res.data.code === 0 && res.data.data) {
message.success('更换成功')
// 重新获取用户登录信息,保持前端展示用户信息数据最新
await loginUserStore.fetchLoginUser()
handleCancel()
props.onSuccess?.()
} else {
message.error(res.data.message || '上传失败')
}
} catch (error: any) {
console.error('上传错误详情:', error)
message.error(error.response?.data?.message || '上传失败,请重试')
} finally {
loading.value = false
}
}
const handleCancel = () => {
open.value = false
fileList.value = []
}
// 记得在组件卸载时释放URL
const handleCancelPreview = () => {
// 释放对象URL,避免内存泄漏
if (previewImage.value.startsWith('blob:')) {
URL.revokeObjectURL(previewImage.value)
}
previewVisible.value = false
}
const openModal = (id: string, avatarUrl) => {
formState.value.id = id
// 将原头像添加到文件列表中显示
if (avatarUrl) {
fileList.value = [
{
uid: '-1', // 使用负值避免冲突
name: '原头像',
status: 'done',
url: avatarUrl,
thumbUrl: avatarUrl,
},
]
} else {
fileList.value = []
}
open.value = true
}
defineExpose({ openModal })
</script>
<style scoped>
.avatar-uploader > .ant-upload {
width: 128px;
height: 128px;
}
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>