上传头像到腾讯云对象存储-前端基于antdv

到腾讯云官网开通对象存储、获取 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>
相关推荐
Nan_Shu_6144 小时前
学习SpringBoot
java·spring boot·后端·学习·spring
Dreams_l4 小时前
初识redis(分布式系统, redis的特性, 基本命令)
数据库·redis·缓存
数据库知识分享者小北4 小时前
Qoder + ADB Supabase :5分钟GET超火AI手办生图APP
数据库·后端
JAVA学习通4 小时前
SpringBoot Layui ThymeLeaf 一点点学习心得
java·spring
考虑考虑4 小时前
JDK25中的StructuredTaskScope
java·后端·java ee
雨过天晴而后无语4 小时前
Windchill的codebase目录打成jar
java·jar
SimonKing4 小时前
「String到Date转换失败」:深挖@RequestBody的日期坑
java·后端·程序员
点亮一颗LED(从入门到放弃)4 小时前
SQLite3数据库——Linux应用
linux·数据库·sqlite
qq_12498707535 小时前
基于Spring Boot的网上招聘服务系统(源码+论文+部署+安装)
java·spring boot·后端·spring·计算机外设