基于腾讯云COS的小程序素材上传功能实现

10 阅读4分钟

基于腾讯云COS的小程序素材上传功能实现

前言

由于开发小程序需要上传图片素材时总是需要后端协助,最近在项目中实现了一个小程序素材上传功能,支持图片上传到腾讯云COS,并提供便捷的链接复制和预览功能,减少沟通合作成本,本文将分享这个功能的实现思路和核心代码。

功能概述

小程序素材上传功能主要包括以下特性:

  • 支持批量上传图片
  • 文件格式和大小校验
  • 自定义上传路径配置
  • 支持链接复制和在线预览
  • 重复文件检测提示
  • 基于腾讯云COS的对象存储

技术方案

1. 整体架构

前端组件 → 腾讯云COS SDK → 后端获取临时凭证 → 对象存储

2. 核心依赖

import COS from 'cos-js-sdk-v5'
import { Modal, Upload, Icon, Form, Select, Input, Radio, message } from 'antd'

3. 上传流程

  1. 用户选择文件类型和业务类型
  2. 配置上传路径参数
  3. 拖拽或点击上传图片
  4. 前端获取临时凭证
  5. 直接上传到COS
  6. 返回文件链接

核心实现

1. 模态框组件结构

const AppletMaterModal = (props) => {
  const { form = {}, visible, onCancel, brandCode } = props
  const { getFieldDecorator, validateFields, getFieldsValue, getFieldValue } = form

  const [fileList, setFileList] = useState([])
  const [cosData, setCosData] = useState(null)
  const [credentials, setCredentials] = useState(null)

  const cosPromiseRef = useRef(null)

  // ...
}

2. 文件校验

在上传前进行严格的格式和大小校验:

const uploadImgValidateFields = async (file) => {
  return new Promise((resolve) => {
    validateFields(async (error, values) => {
      if (error) return resolve(false)

      // 格式校验
      const acceptStr = '.jpeg,.jpg,.png,.gif'
      const checkType = validType(file, acceptStr)
      if (!checkType) {
        message.error('仅支持JPEG/JPG/PNG/GIF格式')
        return resolve(false)
      }

      // 大小校验
      const maxSizeMB = 5
      if (file.size / 1024 / 1024 > maxSizeMB) {
        message.error(`图片大小不能超过${maxSizeMB}MB`)
        return resolve(false)
      }
      resolve(true)
    })
  })
}

// 上传图片校验
const handleBeforeUploadImg = async (file) => {
  return new Promise(async (resolve, reject) => {
    try {
      const checkResult = await uploadImgValidateFields(file)
      if (checkResult) {
        resolve(true)
      }
      resolve(false)
    } catch (error) {
      resolve(false)
    }
  })
}

3. COS实例管理

使用useRef缓存COS实例,避免重复创建:

const getCosInstance = useCallback(async (params) => {
  // 如果已有数据,直接返回
  if (cosData && credentials) {
    return { cos: cosData, credentials }
  }

  // 如果已有正在进行的请求,复用该 Promise
  if (cosPromiseRef.current) {
    return cosPromiseRef.current
  }

  // 创建新的请求
  cosPromiseRef.current = (async () => {
    try {
      const cosInstance = Cos()
      const cosInstanceData = await cosInstance.getCosInstance(params)
      setCosData(cosInstanceData.cos)
      setCredentials(cosInstanceData.credentials)
      return cosInstanceData
    } finally {
      // 请求完成后清除引用
      cosPromiseRef.current = null
    }
  })()
  return cosPromiseRef.current
}, [cosData, credentials])

4. 自定义上传逻辑

const customRequest = useCallback(async (info) => {
  const { file } = info
  const valid = await handleBeforeUploadImg(file)
  if (!valid) return

  const { businessType, path, fileUploadPathContainsDate } = getFieldsValue()
  const { name } = file

  const params = {
    file: file,
    fileType: 1,
    accessType: 1,
    fileNameList: [name],
    fileNums: 1,
    businessType: businessType,
    fileUploadPathContainsDate: fileUploadPathContainsDate,
    addFileUploadPath: path
  }

  try {
    // 等待唯一的 COS 实例数据
    const { cos, credentials } = await getCosInstance(params)
    const response = await Cos().pushObject(params, cos, credentials)
    const imgUrl = {
      name: response.fileName,
      url: `${response.domainUrl}${response.filePath}${response.fileName}`,
    }
    setFileList(prev => [...prev, imgUrl])
    message.success('上传成功')
  } catch (error) {
    if (error.code === 400000) {
      const imgUrl = {
        name: name,
        url: error.data,
        repeat: true
      }
      setFileList(prev => [...prev, imgUrl])
    }
  }
}, [fileList, cosData, credentials])

5. COS工具类封装

export class Cos {
  /**
   * 获取cos实例
   */
  async getCosInstance(params) {
    const credentials = await this.getCredential(params)
    const config = {
      TmpSecretId: credentials.tmpSecretId,
      TmpSecretKey: credentials.tmpSecretKey,
      XCosSecurityToken: credentials.sessionToken,
      StartTime: credentials.startTime,
      ExpiredTime: credentials.expiredTime,
      DomainUrl: credentials.domainUrl,
    }

    this._cos = new COS({
      getAuthorization: (_, callback) => callback(config),
    })
    return { cos: this._cos, credentials: credentials }
  }

  /**
   * 获取credential
   */
  async getCredential(params) {
    const response = await requestService('common.cosFileUpload.getCredential', {
      ...params,
    })
    const { code, data, message } = response

    if (code !== 200) {
      throw new Error(`获取cos credential 错误:${message}`)
    }

    const { expiredTime, fileUploadPath, credential, bucket, region, startTime, domainUrl } = data

    return {
      expiredTime,
      startTime,
      fileUploadPath,
      bucket,
      region,
      domainUrl,
      ...credential,
    }
  }

  /**
   * push 文件对象
   */
  async pushObject(config, cos, credentials) {
    const { fileUploadPath, bucket, region, domainUrl } = credentials
    const { key, ...record } = this.createRecord(config, { filePath: fileUploadPath })
    const { file, onProgress = null } = config

    return new Promise((resolve, reject) => {
      const payload = {
        Bucket: bucket,
        Region: region,
        Key: key,
        StorageClass: 'STANDARD',
        Body: file,
        onProgress: onProgress,
      }

      cos.putObject(payload, (err, responseData) => {
        if (err) {
          reject({ message: '上传异常,请稍后重试' })
          return
        }
        resolve({ ...responseData, ...record, domainUrl })
      })
    })
  }

  /**
   * 生成日志记录
   */
  createRecord = (config, params) => {
    const { filePath } = params
    const { accessType, module, file = {}, fileNameList = [] } = config
    const { size: fileSize, type = '' } = file
    let suffix = type.split('/')[1]
    if (!type) {
      const index = file.name.lastIndexOf('.')
      suffix = file.name.substring(index + 1)
    }

    const fileName = fileNameList.length ? `${fileNameList[0]}` : `${uuidv4().replace(/-/g, '')}.${suffix}`

    const record = {
      accessType,
      fileName,
      fileSize: fileSize / 1024,
      module,
      suffix,
      key: filePath + fileName,
      ...params,
    }
    return record
  }
}

亮点功能

1. 智能路径配置

支持自定义上传路径,可选择是否添加时间目录:

<Form.Item label="追加路径">
  {getFieldDecorator('path', {
    initialValue: 'gg',
    rules: [{ required: false }],
  })(
    <Input onChange={() => handleResetCos()} />
  )}
</Form.Item>

<Form.Item label="路径添加时间目录">
  {getFieldDecorator('fileUploadPathContainsDate', {
    initialValue: 1,
    rules: [{ required: false }],
  })(
    <Radio.Group onChange={() => handleResetCos()}>
      <Radio value={1}></Radio>
      <Radio value={0}></Radio>
    </Radio.Group>
  )}
</Form.Item>

2. 链接复制功能

提供便捷的链接复制功能,支持现代浏览器和兼容旧浏览器:

const handleCopy = (item) => {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(item.url)
      .then(() => {
        message.success('链接已复制到剪贴板')
      })
      .catch((err) => {
        message.error('复制失败: ', err)
      })
  } else {
    // Fallback for older browsers
    const textArea = document.createElement('textarea')
    textArea.value = item.url
    document.body.appendChild(textArea)
    textArea.select()
    document.execCommand('copy')
    document.body.removeChild(textArea)
    message.success('链接已复制到剪贴板')
  }
}

3. 重复文件检测

上传时检测文件是否已存在,并给出提示:

try {
  const { cos, credentials } = await getCosInstance(params)
  const response = await Cos().pushObject(params, cos, credentials)
  const imgUrl = {
    name: response.fileName,
    url: `${response.domainUrl}${response.filePath}${response.fileName}`,
  }
  setFileList(prev => [...prev, imgUrl])
  message.success('上传成功')
} catch (error) {
  if (error.code === 400000) {
    const imgUrl = {
      name: name,
      url: error.data,
      repeat: true
    }
    setFileList(prev => [...prev, imgUrl])
  }
}

4. 拖拽上传体验

使用Ant Design的Dragger组件,提供友好的拖拽上传体验:

const draggerProps = {
  name: 'file',
  multiple: true,
  accept: 'image/jpeg,image/jpg,image/png,image/gif',
  action: '',
  showUploadList: false,
  beforeUpload: () => true,
  customRequest: customRequest
}

<Dragger {...draggerProps} style={{maxWidth: '500px'}}>
  <p className="ant-upload-drag-icon">
    <Icon type="inbox" />
  </p>
  <p className="ant-upload-text">点击或拖动图片到此区域进行上传</p>
  <p className="ant-upload-hint">支持:.jpeg,.jpg,.png,.gif格式,大小不超过5Mb</p>
  <p className="ant-upload-hint">支持单次或批量上传</p>
</Dragger>

遇到的问题和解决方案

1. COS实例重复创建

问题:每次上传都创建新的COS实例,导致性能浪费

解决方案:使用useRef缓存COS实例和Promise,确保同一批次上传复用同一个实例

const cosPromiseRef = useRef(null)

const getCosInstance = useCallback(async (params) => {
  if (cosData && credentials) {
    return { cos: cosData, credentials }
  }

  if (cosPromiseRef.current) {
    return cosPromiseRef.current
  }

  cosPromiseRef.current = (async () => {
    try {
      const cosInstance = Cos()
      const cosInstanceData = await cosInstance.getCosInstance(params)
      setCosData(cosInstanceData.cos)
      setCredentials(cosInstanceData.credentials)
      return cosInstanceData
    } finally {
      cosPromiseRef.current = null
    }
  })()
  return cosPromiseRef.current
}, [cosData, credentials])

2. 临时凭证过期

问题:临时凭证有过期时间,需要及时更新

解决方案:在getCosInstance中检查凭证状态,过期时自动重新获取

const getCosInstance = useCallback(async (params) => {
  // 如果已有数据,直接返回
  if (cosData && credentials) {
    return { cos: cosData, credentials }
  }

  // 否则重新获取
  cosPromiseRef.current = (async () => {
    try {
      const cosInstance = Cos()
      const cosInstanceData = await cosInstance.getCosInstance(params)
      setCosData(cosInstanceData.cos)
      setCredentials(cosInstanceData.credentials)
      return cosInstanceData
    } finally {
      cosPromiseRef.current = null
    }
  })()
  return cosPromiseRef.current
}, [cosData, credentials])

3. 批量上传并发控制

问题:批量上传时可能出现并发冲突

解决方案:使用Promise.all管理批量上传,确保所有上传任务完成后再更新状态

async pushObject(config, cos, credentials) {
  const { file } = config
  if (Array.isArray(file)) {
    const promiseAll = []
    file.map((item, index) => {
      config.file = item
      promiseAll.push(this.uploadReal(config, cos, credentials))
    })
    return Promise.all(promiseAll.map((p) => p.catch(() => {})))
  } else {
    return this.uploadReal(config, cos, credentials)
  }
}

4. 文件格式校验

问题:需要确保上传的文件格式符合要求

解决方案:在上传前进行严格的格式校验

const uploadImgValidateFields = async (file) => {
  return new Promise((resolve) => {
    validateFields(async (error, values) => {
      if (error) return resolve(false)

      // 格式校验
      const acceptStr = '.jpeg,.jpg,.png,.gif'
      const checkType = validType(file, acceptStr)
      if (!checkType) {
        message.error('仅支持JPEG/JPG/PNG/GIF格式')
        return resolve(false)
      }

      // 大小校验
      const maxSizeMB = 5
      if (file.size / 1024 / 1024 > maxSizeMB) {
        message.error(`图片大小不能超过${maxSizeMB}MB`)
        return resolve(false)
      }
      resolve(true)
    })
  })
}

完整组件代码

import React, { memo, useCallback, useState, useRef } from 'react'
import { Modal, Upload, Icon, Form, Select, Input, Radio, message } from 'antd'
import Cos from './cos'
import { getBrandMap } from 'constant'
import {
  getBusinessTypeList,
  validType,
  fileTypeList,
} from 'scrmMessage/constans'
import { initial } from 'lodash'

import styles from './index.scss'

const { Dragger } = Upload

const formItemLayout = {
  labelCol: {
    xs: { span: 24 },
    sm: { span: 6 },
  },
  wrapperCol: {
    xs: { span: 24 },
    sm: { span: 18 },
  },
}

const brandMap = getBrandMap()

const AppletMaterModal = (props) => {
  const { form = {}, visible, onCancel, brandCode } = props
  const { getFieldDecorator, validateFields, getFieldsValue, getFieldValue } = form

  const cosPromiseRef = useRef(null)

  const [fileList, setFileList] = useState([])
  const [cosData, setCosData] = useState(null)
  const [credentials, setCredentials] = useState(null)

  const uploadImgValidateFields = async (file) => {
    return new Promise((resolve) => {
      validateFields(async (error, values) => {
        if (error) return resolve(false)

        const acceptStr = '.jpeg,.jpg,.png,.gif'
        const checkType = validType(file, acceptStr)
        if (!checkType) {
          message.error('仅支持JPEG/JPG/PNG/GIF格式')
          return resolve(false)
        }

        const maxSizeMB = 5
        if (file.size / 1024 / 1024 > maxSizeMB) {
          message.error(`图片大小不能超过${maxSizeMB}MB`)
          return resolve(false)
        }
        resolve(true)
      })
    })
  }

  const handleBeforeUploadImg = async (file) => {
    return new Promise(async (resolve, reject) => {
      try {
        const checkResult = await uploadImgValidateFields(file)
        if (checkResult) {
          resolve(true)
        }
        resolve(false)
      } catch (error) {
        resolve(false)
      }
    })
  }

  const handleCopy = (item) => {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(item.url)
        .then(() => {
          message.success('链接已复制到剪贴板')
        })
        .catch((err) => {
          message.error('复制失败: ', err)
        })
    } else {
      const textArea = document.createElement('textarea')
      textArea.value = item.url
      document.body.appendChild(textArea)
      textArea.select()
      document.execCommand('copy')
      document.body.removeChild(textArea)
      message.success('链接已复制到剪贴板')
    }
  }

  const getCosInstance = useCallback(async (params) => {
    if (cosData && credentials) {
      return { cos: cosData, credentials }
    }

    if (cosPromiseRef.current) {
      return cosPromiseRef.current
    }

    cosPromiseRef.current = (async () => {
      try {
        const cosInstance = Cos()
        const cosInstanceData = await cosInstance.getCosInstance(params)
        setCosData(cosInstanceData.cos)
        setCredentials(cosInstanceData.credentials)
        return cosInstanceData
      } finally {
        cosPromiseRef.current = null
      }
    })()
    return cosPromiseRef.current
  }, [cosData, credentials])

  const customRequest = useCallback(async (info) => {
    const { file } = info
    const valid = await handleBeforeUploadImg(file)
    if (!valid) return

    const { businessType, path, fileUploadPathContainsDate } = getFieldsValue()
    const { name } = file

    const params = {
      file: file,
      fileType: 1,
      accessType: 1,
      fileNameList: [name],
      fileNums: 1,
      businessType: businessType,
      fileUploadPathContainsDate: fileUploadPathContainsDate,
      addFileUploadPath: path
    }

    try {
      const { cos, credentials } = await getCosInstance(params)
      const response = await Cos().pushObject(params, cos, credentials)
      const imgUrl = {
        name: response.fileName,
        url: `${response.domainUrl}${response.filePath}${response.fileName}`,
      }
      setFileList(prev => [...prev, imgUrl])
      message.success('上传成功')
    } catch (error) {
      if (error.code === 400000) {
        const imgUrl = {
          name: name,
          url: error.data,
          repeat: true
        }
        setFileList(prev => [...prev, imgUrl])
      }
    }
  }, [fileList, cosData, credentials])

  const draggerProps = {
    name: 'file',
    multiple: true,
    accept: 'image/jpeg,image/jpg,image/png,image/gif',
    action: '',
    showUploadList: false,
    beforeUpload: () => true,
    customRequest: customRequest
  }

  const handleResetCos = () => {
    setCosData(null)
    setCredentials(null)
  }

  return (
    <Modal
      title="上传图片"
      visible={visible}
      footer={null}
      okText="确定"
      cancelText="取消"
      width={500}
      onCancel={onCancel}
      maskClosable={false}
      destroyOnClose
    >
      <div>
        <Form>
          <Form.Item label="品牌" {...formItemLayout}>
            <span>{brandMap[brandCode]}</span>
          </Form.Item>
          <Form.Item label="文件类型" {...formItemLayout}>
            {getFieldDecorator('fileType', {
              initialValue: 1,
              rules: [{ required: true, message: '请选择文件类型' }],
            })(
              <Radio.Group onChange={() => handleResetCos()}>
                <Radio value={1}>图片</Radio>
                <Radio disabled={true} value={2}>文件</Radio>
                <Radio disabled={true} value={3}>视频/音频</Radio>
              </Radio.Group>
            )}
          </Form.Item>
          <Form.Item label="业务类型" {...formItemLayout}>
            {getFieldDecorator('businessType', {
              rules: [{ required: true, message: '请选择业务类型' }],
            })(
              <Select onChange={() => handleResetCos()}>
                {getBusinessTypeList(brandCode).map((item, index) => (
                  <Select.Option key={index} value={item.value}>{item.label}</Select.Option>
                ))}
              </Select>
            )}
          </Form.Item>
          <Form.Item label="追加路径" {...formItemLayout}>
            {getFieldDecorator('path', {
              initialValue: 'gg',
              rules: [{ required: false }],
            })(
              <Input onChange={() => handleResetCos()} />
            )}
          </Form.Item>
          <Form.Item label="路径添加时间目录" {...formItemLayout}>
            {getFieldDecorator('fileUploadPathContainsDate', {
              initialValue: 1,
              rules: [{ required: false }],
            })(
              <Radio.Group onChange={() => handleResetCos()}>
                <Radio value={1}></Radio>
                <Radio value={0}></Radio>
              </Radio.Group>
            )}
          </Form.Item>
        </Form>

        {getFieldValue('fileType') == 1 ? <Dragger {...draggerProps} style={{maxWidth: '500px'}}>
          <p className="ant-upload-drag-icon">
            <Icon type="inbox" />
          </p>
          <p className="ant-upload-text">点击或拖动图片到此区域进行上传</p>
          <p className="ant-upload-hint">支持:.jpeg,.jpg,.png,.gif格式,大小不超过5Mb</p>
          <p className="ant-upload-hint">支持单次或批量上传</p>
        </Dragger>:null}

        {fileList.map((item, index) => (
          <div key={index} style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
            <div className={item?.repeat?styles.AppletMaterModal_red:''}>{item.name}</div>
            <div>
              <Icon onClick={() => window.open(item.url, '_blank')} style={{ cursor: 'pointer', margin: '0 20px' }} type="eye" theme="twoTone" />
              <Icon onClick={() => handleCopy(item)} style={{ cursor: 'pointer' }} type="copy" theme="twoTone" />
            </div>
          </div>
        ))}
      </div>
    </Modal>
  )
}

export default memo(Form.create()(AppletMaterModal))

总结

在实际使用中,该功能表现稳定,能够满足日常的小程序素材管理需求。后续可以考虑添加进度条显示、断点续传、图片压缩等高级功能,进一步提升用户体验。

参考文档


希望这篇文章对你有所帮助!如有问题欢迎交流讨论。