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

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

前言

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

功能概述

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

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

技术方案

1. 整体架构

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

2. 核心依赖

javascript 复制代码
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. 模态框组件结构

jsx 复制代码
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. 文件校验

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

javascript 复制代码
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实例,避免重复创建:

javascript 复制代码
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. 自定义上传逻辑

javascript 复制代码
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工具类封装

javascript 复制代码
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. 智能路径配置

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

jsx 复制代码
<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. 链接复制功能

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

javascript 复制代码
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. 重复文件检测

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

javascript 复制代码
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组件,提供友好的拖拽上传体验:

jsx 复制代码
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,确保同一批次上传复用同一个实例

javascript 复制代码
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中检查凭证状态,过期时自动重新获取

javascript 复制代码
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管理批量上传,确保所有上传任务完成后再更新状态

javascript 复制代码
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. 文件格式校验

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

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

javascript 复制代码
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)
    })
  })
}

完整组件代码

jsx 复制代码
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))

总结

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

参考文档


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

相关推荐
AlienZHOU4 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc8 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师9 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen9 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒10 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow10 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81810 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007810 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明10 小时前
第 6 章 组件进阶
前端·vue.js
江华森10 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端