微信小程序的图片/视频上传功能,小程序官网是提供了相关的API语法。本例使用了 wx.chooseMedia 选择或拍摄图片/视频附件,通过 wx.uploadFile 方法上传至服务器,在需要的地方将服务器存储的附件地址查询出来提供展示预览。预览主要实现了图片的手势缩放及托动,视频的可全屏播放等功能。

本例主要使用了taro及taro ui 组件开发,与小程序官方语言基本兼容,定义了附件的上传及预览taro组件,具体代码如下:

1.部分样式代码(customAnnex.styl):

.at-row {
  display: -webkit-box;
  display: -webkit-flex;
  display: -ms-flexbox;
  display: flex;
  width: 100%; }
.at-row__direction--row {
  -webkit-box-orient: horizontal;
  -webkit-box-direction: normal;
  -webkit-flex-direction: row;
  -ms-flex-direction: row;
  flex-direction: row; }
.at-row__direction--column {
  -webkit-box-orient: vertical;
  -webkit-box-direction: normal;
  -webkit-flex-direction: column;
  -ms-flex-direction: column;
  flex-direction: column; }
.at-row__direction--row-reverse {
  -webkit-box-orient: horizontal;
  -webkit-box-direction: reverse;
  -webkit-flex-direction: row-reverse;
  -ms-flex-direction: row-reverse;
  flex-direction: row-reverse; }
.at-row__direction--column-reverse {
  -webkit-box-orient: vertical;
  -webkit-box-direction: reverse;
  -webkit-flex-direction: column-reverse;
  -ms-flex-direction: column-reverse;
  flex-direction: column-reverse; }
.at-row__align--start {
  -webkit-align-items: flex-start;
  -ms-flex-align: start;
  align-items: flex-start;
  -webkit-box-align: start; }
.at-row__align--end {
  -webkit-align-items: flex-end;
  -ms-flex-align: end;
  align-items: flex-end;
  -webkit-box-align: end; }
.at-row__align--center {
  -webkit-align-items: center;
  -ms-flex-align: center;
  align-items: center;
  -webkit-box-align: center; }
.at-row__align--stretch {
  -webkit-align-items: stretch;
  -ms-flex-align: stretch;
  align-items: stretch;
  -webkit-box-align: stretch; }
.at-row__align--baseline {
  -webkit-align-items: baseline;
  -ms-flex-align: baseline;
  align-items: baseline;
  -webkit-box-align: baseline; }
.at-row__justify--start {
  -webkit-justify-content: flex-start;
  -ms-flex-pack: start;
  justify-content: flex-start;
  -webkit-box-pack: start; }
.at-row__justify--end {
  -webkit-justify-content: flex-end;
  -ms-flex-pack: end;
  justify-content: flex-end;
  -webkit-box-pack: end; }
.at-row__justify--center {
  -webkit-justify-content: center;
  -ms-flex-pack: center;
  justify-content: center;
  -webkit-box-pack: center; }
.at-row__justify--between {
  -webkit-justify-content: space-between;
  -ms-flex-pack: justify;
  justify-content: space-between;
  -webkit-box-pack: justify; }
.at-row__justify--around {
  -webkit-justify-content: space-around;
  -ms-flex-pack: distribute;
  justify-content: space-around;
  -webkit-box-pack: space-around; }
.at-row__align-content--start {
  -webkit-align-content: flex-start;
  -ms-flex-line-pack: start;
  align-content: flex-start; }
.at-row__align-content--end {
  -webkit-align-content: flex-end;
  -ms-flex-line-pack: end;
  align-content: flex-end; }
.at-row__align-content--center {
  -webkit-align-content: center;
  -ms-flex-line-pack: center;
  align-content: center; }
.at-row__align-content--between {
  -webkit-align-content: space-between;
  -ms-flex-line-pack: justify;
  align-content: space-between; }
.at-row__align-content--around {
  -webkit-align-content: space-around;
  -ms-flex-line-pack: distribute;
  align-content: space-around; }
.at-row__align-content--stretch {
  -webkit-align-content: stretch;
  -ms-flex-line-pack: stretch;
  align-content: stretch; }
.at-row--no-wrap {
  -webkit-flex-wrap: nowrap;
  -ms-flex-wrap: nowrap;
  flex-wrap: nowrap; }
.at-row--wrap {
  -webkit-flex-wrap: wrap;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap; }
.at-row--wrap-reverse {
  -webkit-flex-wrap: wrap-reverse;
  -ms-flex-wrap: wrap-reverse;
  flex-wrap: wrap-reverse; }

.at-col {
  -webkit-flex: 1;
  -ms-flex: 1;
  flex: 1;
  -webkit-box-flex: 1;
  width: 100%;
  display: block;
  white-space: nowrap;
  -webkit-box-sizing: border-box;
  box-sizing: border-box; }
.at-col-1 {
  -webkit-flex: 0 0 8.33333%;
  -ms-flex: 0 0 8.33333%;
  flex: 0 0 8.33333%;
  -webkit-box-flex: 0;
  max-width: 8.33333%; }
.at-col__offset-1 {
  margin-left: 8.33333%; }
.at-col-2 {
  -webkit-flex: 0 0 16.66667%;
  -ms-flex: 0 0 16.66667%;
  flex: 0 0 16.66667%;
  -webkit-box-flex: 0;
  max-width: 16.66667%; }
.at-col__offset-2 {
  margin-left: 16.66667%; }
.at-col-3 {
  -webkit-flex: 0 0 25%;
  -ms-flex: 0 0 25%;
  flex: 0 0 25%;
  -webkit-box-flex: 0;
  max-width: 25%; }
.at-col__offset-3 {
  margin-left: 25%; }
.at-col-4 {
  -webkit-flex: 0 0 33.33333%;
  -ms-flex: 0 0 33.33333%;
  flex: 0 0 33.33333%;
  -webkit-box-flex: 0;
  max-width: 33.33333%; }
.at-col__offset-4 {
  margin-left: 33.33333%; }
.at-col-5 {
  -webkit-flex: 0 0 41.66667%;
  -ms-flex: 0 0 41.66667%;
  flex: 0 0 41.66667%;
  -webkit-box-flex: 0;
  max-width: 41.66667%; }
.at-col__offset-5 {
  margin-left: 41.66667%; }
.at-col-6 {
  -webkit-flex: 0 0 50%;
  -ms-flex: 0 0 50%;
  flex: 0 0 50%;
  -webkit-box-flex: 0;
  max-width: 50%; }
.at-col__offset-6 {
  margin-left: 50%; }
.at-col-7 {
  -webkit-flex: 0 0 58.33333%;
  -ms-flex: 0 0 58.33333%;
  flex: 0 0 58.33333%;
  -webkit-box-flex: 0;
  max-width: 58.33333%; }
.at-col__offset-7 {
  margin-left: 58.33333%; }
.at-col-8 {
  -webkit-flex: 0 0 66.66667%;
  -ms-flex: 0 0 66.66667%;
  flex: 0 0 66.66667%;
  -webkit-box-flex: 0;
  max-width: 66.66667%; }
.at-col__offset-8 {
  margin-left: 66.66667%; }
.at-col-9 {
  -webkit-flex: 0 0 75%;
  -ms-flex: 0 0 75%;
  flex: 0 0 75%;
  -webkit-box-flex: 0;
  max-width: 75%; }
.at-col__offset-9 {
  margin-left: 75%; }
.at-col-10 {
  -webkit-flex: 0 0 83.33333%;
  -ms-flex: 0 0 83.33333%;
  flex: 0 0 83.33333%;
  -webkit-box-flex: 0;
  max-width: 83.33333%; }
.at-col__offset-10 {
  margin-left: 83.33333%; }
.at-col-11 {
  -webkit-flex: 0 0 91.66667%;
  -ms-flex: 0 0 91.66667%;
  flex: 0 0 91.66667%;
  -webkit-box-flex: 0;
  max-width: 91.66667%; }
.at-col__offset-11 {
  margin-left: 91.66667%; }
.at-col-12 {
  -webkit-flex: 0 0 100%;
  -ms-flex: 0 0 100%;
  flex: 0 0 100%;
  -webkit-box-flex: 0;
  max-width: 100%; }
.at-col__offset-12 {
  margin-left: 100%; }
.at-col__align--top {
  -webkit-align-self: flex-start;
  -ms-flex-item-align: start;
  align-self: flex-start; }
.at-col__align--bottom {
  -webkit-align-self: flex-end;
  -ms-flex-item-align: end;
  align-self: flex-end; }
.at-col__align--center {
  -webkit-align-self: center;
  -ms-flex-item-align: center;
  align-self: center; }
.at-col--auto {
  max-width: initial;
  word-break: keep-all; }
.at-col--wrap {
  white-space: normal;
  word-wrap: break-word; }

.annex {
  position: fixed;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  visibility: hidden;
  -webkit-transition: visibility 200ms ease-in;
  -o-transition: visibility 200ms ease-in;
  transition: visibility 200ms ease-in;
  z-index: 1000;
}
.annex--active {
  visibility: visible;
}
.annex--active .annex__overlay,
.annex--active .annex__container {
  opacity: 1;
}
.annex__overlay {
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  position: absolute;
  background-color: rgba(0, 0, 0, 0.3);
}
.annex__container {
  position: absolute;
  top: 50%;
  left: 50%;
  -webkit-transform: translate(-50%, -50%);
  -ms-transform: translate(-50%, -50%);
  transform: translate(-50%, -50%);
  width: 600rpx;
  max-height: calc(100vh - 300rpx);
  overflow: hidden;
  border-radius: 12rpx;
}
.annex__overlay, .annex__container {
  opacity: 0;
  -webkit-transition: opacity 200ms ease-in;
  -o-transition: opacity 200ms ease-in;
  transition: opacity 200ms ease-in;
}


.at-relative {
  position: relative;
}
.at-col-class {
  width: 222rpx !important;
  height: 222rpx !important;
  margin: 5rpx;
  text-align: center;
  border: 2rpx #d6e4ef solid;
  position: relative;
  display: block;
  white-space: nowrap;
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
}
.at-col-img {
  width: 218rpx !important;
  height: 218rpx !important;
}
.at-col-close {
  width: 36rpx;
  height: 36rpx;
  position: absolute;
  top: 2rpx;
  right: 2rpx;
  border-radius: 100%;
  background-color: #ffffff;
  z-index: 99;
}
.at-col-add-img {
  width:47rpx;
  height:47rpx;
  margin: 69rpx 85rpx auto 85rpx;
}
.at-col-add-text {
  margin-top: 30rpx;
  color: #999999;
}
.resultFont {
  font-size:32rpx;
  margin-top:30rpx;
}
.count_color {
  color: #CCCCCC;
}
.hide {
  display: none !important;
}
.show {
  display: block;
}
.w100 {
  width: 100%;
}

2.部分逻辑代码(customAnnex.tsx):

import Taro, { Config, Component } from '@tarojs/taro'
import './customAnnex.styl'
import {View, Image, Video} from '@tarojs/components'
import annexAdd from '../../../asset/images/index/annex-add.png'
import annexClose from '../../../asset/images/index/annex-close.png'
import blankImg from '../../../asset/images/index/blank.png'
import TaroReq from '../../../constants/tokenHandle.js'
import api from "../../../constants/api.json"
export default class customAnnex extends Component {

    /**自定义的组件
     * 指定config的类型声明为: Taro.Config
     *
     * 由于 typescript 对于 object 类型推导只能推出 Key 的基本类型
     * 对于像 navigationBarTextStyle: 'black' 这样的推导出的类型是 string
     * 提示和声明 navigationBarTextStyle: 'black' | 'white' 类型冲突, 需要显示声明类型
     */
    /**
     * *************重要API*************
     * 作用:附件的上传
     * 其余的需要的时候再扩展
     * 使用:1、导入组件:import Cannex from '../../components/custom-annex/customAnnex'
     *      2、使用组件: <Cannex></Cannex>
     *
     */
    config: Config = { };

    maxNum: number = 3; // 最大附件上传数量
    maxWidth: number = 300; // 最大预览宽度

    constructor(props) {
        super(props);
        this.state = {
          annexFiles: [], // 附件信息列表
          addShow: true, // 是否展示添加设备
          isOpened: false, // 展示的附件区域
          annexShow: null, // 展示的附件内容
          touch: {
            distance: 0,
            scale: 1,
            baseWidth: null,
            baseHeight: null,
            scaleWidth: null,
            scaleHeight: null,
            startX: null,
            startY: null,
            moveTop: null,
            moveLeft: null,
            previousTwoFinger: false //此变量防止双指离开时,后离开的手指会触发图片移动
          }
        };
    }

    componentDidMount() {
      try {
        setTimeout(() => {
          this.requstAnnex();
        }, 500);
      } catch (error) { }
    }

    //输入框赋值
    touchSetHandle(valueMap: object) {
      if (valueMap) {
        for (let key in valueMap) {
          this.state.touch[key]=valueMap[key];
        }
        this.setState({
          touch: this.state.touch
        });
      }
    }

    // 结束
    touchEndHandle() {
      setTimeout(()=>{
        this.touchSetHandle({
          previousTwoFinger: false
        })
      }, 1000);
    }

    touchStartHandle(e) {
      // 单手指缩放开始
      if (e.touches.length == 1) {
        if (!this.state.touch.previousTwoFinger){
          let startX = e.touches[0].clientX;
          let startY = e.touches[0].clientY;
          this.touchSetHandle({ startX: startX, startY: startY });
        }
      } else {
        // 注意touchstartCallback 真正代码的开始
        // 当两根手指放上去的时候,就将distance 初始化。
        let xMove = e.touches[1].clientX - e.touches[0].clientX;
        let yMove = e.touches[1].clientY - e.touches[0].clientY;
        let distance = Math.sqrt(xMove * xMove + yMove * yMove);
        this.touchSetHandle({ distance: distance });
      }
    }

    touchMoveHandle(e) {
      let touch = this.state.touch;
      // 单手指移动
      if (e.touches.length == 1) {
        // 一只手指触摸触发移动
        if (!this.state.touch.previousTwoFinger){
          let moveX = this.state.touch.moveLeft + e.touches[0].clientX - this.state.touch.startX;
          let moveY = this.state.touch.moveTop + e.touches[0].clientY - this.state.touch.startY;
          let deffWidth = this.state.touch.baseWidth - this.state.touch.scaleWidth;
          let deffHeight = this.state.touch.baseHeight - this.state.touch.scaleHeight;
          let calcFun = function (deff, move) {
            if (deff >= 0 ) {
              if (deff <= move) {
                move = deff;
              } else {
                move = move > 0 ? move : 0;
              }
            } else {
              if (move <= deff) {
                move = deff;
              } else {
                move = move < 0 ? move : 0;
              }
            }
            return move;
          };
          moveX = calcFun(deffWidth, moveX);
          moveY = calcFun(deffHeight, moveY);
          let startX = e.touches[0].clientX;
          let startY = e.touches[0].clientY;
          this.touchSetHandle({ moveLeft: moveX, moveTop: moveY, startX: startX, startY: startY });
        }
      } else {
        let xMove = e.touches[1].clientX - e.touches[0].clientX;
        let yMove = e.touches[1].clientY - e.touches[0].clientY;
        // 新的 ditance
        let distance = Math.sqrt(xMove * xMove + yMove * yMove);
        let distanceDiff = distance - touch.distance;
        let newScale = touch.scale + 0.005 * distanceDiff;
        // 为了防止缩放得太大,所以scale需要限制,同理最小值也是
        if (newScale >= 4) {
          newScale = 4;
        }
        if (newScale <= 1) {
          newScale = 1;
        }
        let scaleWidth = (newScale * touch.baseWidth).toFixed(2);
        let scaleHeight = (newScale * touch.baseHeight).toFixed(2);
        // 赋值 新的 => 旧的
        this.touchSetHandle({
          distance: distance,
          scale: newScale,
          scaleWidth: scaleWidth,
          scaleHeight: scaleHeight,
          diff: distanceDiff,
          previousTwoFinger: true
        });
      }
    }

    imgLoad(e) {
      // 这个api是<image>组件的api类似<img>的onload属性
      let width = this.maxWidth; // 预览最大宽度
      let height = (width / e.detail.width * e.detail.height).toFixed(2);
      this.touchSetHandle({
        baseWidth: width,
        baseHeight: height,
        scaleWidth: width,
        scaleHeight: height,
        moveTop: 0,
        moveLeft: 0,
        previousTwoFinger: false
      });
    }

    /*控制附件新增按钮*/
    setAddShow() {
      if (this.state.annexFiles && this.state.annexFiles.length >= this.maxNum) {
        this.setState({
          addShow: false
        });
      } else {
        this.setState({
          addShow: true
        });
      }
    }

    /*添加附件*/
    addAnnex() {
      let that = this;
      wx.chooseMedia({
        count: 1,
        mediaType: ['image','video'],
        sourceType: ['album', 'camera'],
        sizeType: ['original', 'compressed'],
        maxDuration: 20,
        camera: 'back',
        success(res) {
          if (res && res.tempFiles && res.tempFiles.length > 0) {
            let annexFiles: any[] = [...that.state.annexFiles];
            let image = res.type=="video" ? res.tempFiles[0].thumbTempFilePath : res.tempFiles[0].tempFilePath;
            annexFiles.push({ type: res.type, path: res.tempFiles[0].tempFilePath, iconPath: image });
            that.setState({
              annexFiles: annexFiles
            }, () => {
              that.setAddShow();
            });
          }
        },
        fail() {
          Taro.showToast({
            title: '附件添加失败!',
            icon: 'none',
            duration: 3000
          })
        }
      });
    }

    /*删除附件*/
    delAnnex(index) {
      let annexFiles: any[] = this.state.annexFiles.filter((val, i) => i != index);
      this.setState({
        annexFiles: annexFiles
      }, () => {
        this.setAddShow();
      });
    }

    /*展示附件*/
    clickAnnexShow(annex) {
      if (annex && annex.type) {
        this.setState({
          annexShow: annex,
          isOpened: true
        });
      }
    }

    /*控制展示*/
    clickAnnexHandle(flag) {
      this.setState({
        annexShow: null,
        isOpened: flag
      });
    }

    /*请求附件*/
    requstAnnex() {
      const { isUpload, idName, idValue } = this.props;
      if(isUpload) { return; }
      this.setState({
        addShow: false,
      });
      if(idName && idValue) {
        let formData: object = {page: 1, rows: 100};
        formData[idName] = idValue;
        TaroReq.get(api.url + api.attachMentList, formData).then(res => {
          if (res.statusCode == 200) {
            const data = res.data.rows || [];
            let annexFiles: any = [];
            for (let index=0; index<data.length; index++) {
              let type = data[index].type=="mp4" ? "video":data[index].type;
              annexFiles.push({ type: type, path:  api.url + data[index].path, iconPath:  api.url + data[index].iconPath });
            }
            this.setState({
              annexFiles: annexFiles
            });
          }
        });
      }
    }

  //多附件上传
  uploadMuliteFile(data) {
    let that = this,
      i = data.i ? data.i : 0,
      success = data.success ? data.success : 0,
      fail = data.fail ? data.fail : 0;
    wx.uploadFile({
      header: data.header,
      url: data.url,
      filePath: data.path[i],
      name: 'file',
      formData: data.formData,
      success: (res) => {
        success++;
      },
      fail: (res) => {
        fail++;
      },
      complete: () => {
        i++;
        if (i == data.path.length) { //当图片传完时,停止调用
          Taro.hideLoading();
          const tip = fail > 0 ? (success+'个附件上传成功,' + fail + '个附件上传失败!') : ('附件上传成功!');
          Taro.showToast({
            title: tip,
            icon: 'none',
            duration: 3000
          });
          if (data.callFun && typeof data.callFun == "function") {
            data.callFun();
          }
        } else { //若图片还没有传完,则继续调用函数
          data.i = i;
          data.success = success;
          data.fail = fail;
          that.uploadMuliteFile(data);//递归,回调自己
        }
      }
    });
  }

    /*上传附件*/
    uploadAnnex(params) {
      const annexFilePaths: any[] = [];
      for (let i = 0; i < this.state.annexFiles.length; i++) {
        annexFilePaths.push(this.state.annexFiles[i].path);
      }
      if (annexFilePaths && annexFilePaths.length > 0) {
        Taro.showLoading({
          title: '附件上传中...',
          mask: true
        });
        let reqToken = Taro.getStorageSync('access_token_' + 'TM-WXapplet');
        let reqHeader = { 'content-type': params.contentType || 'multipart/form-data', Authorization: 'Bearer ' + reqToken };
        this.uploadMuliteFile({
          header: reqHeader,
          url: params.url || (api.url + api.attachMentUpload),
          path: annexFilePaths,
          formData: params.formData || null,
          callFun: params.callFun || null
        });
      }
    }

    render() {
      const { isUpload, idName, idValue } = this.props;
      const { annexFiles, addShow, isOpened, annexShow, touch } = this.state;
      return (
        <View>

          {isUpload
            ? <View>

                <View className="at-row resultFont">
                  <View className="at-col at-col-11">上传附件</View>
                  <View className="at-col at-col-1 count_color">{annexFiles.length}/{this.maxNum}</View>
                </View>

                <View className="at-row at-relative at-row--wrap resultFont">
                  {annexFiles.map((annexFile, index) =>
                    <View className="at-col-class">
                      <Image className="at-col-img" mode='aspectFit' src={annexFile.iconPath} onClick={this.clickAnnexShow.bind(this, annexFile)}></Image>
                      <Image className="at-col-close" src={annexClose} onClick={this.delAnnex.bind(this, index)}></Image>
                    </View>
                  )}
                  <View className="at-col-class {{addShow?'show':'hide'}}" onClick={this.addAnnex.bind(this)}>
                    <Image className="at-col-add-img" src={annexAdd}></Image>
                    <View className="at-col-add-text">视频/图片</View>
                  </View>
                </View>

              </View>
            : <View className="at-row at-relative at-row--wrap resultFont">
                {annexFiles.map((annexFile) =>
                  <View className="at-col-class">
                    <Image className="at-col-img" mode='aspectFit' src={annexFile.iconPath} onClick={this.clickAnnexShow.bind(this, annexFile)}></Image>
                  </View>
                )}
              </View>
          }

          <View className="annex {{isOpened?'annex--active':''}}">
            <View className="annex__overlay" onClick={this.clickAnnexHandle.bind(this, false)}></View>
            {(annexShow.type && annexShow.path)
              ? <View className="annex__container">
                {annexShow.type=="video"
                  ? <View style="position: relative;">
                    <Video
                      objectFit="cover"
                      src={annexShow.path}
                      controls={true}
                      autoplay={true}
                      initialTime={0}
                      loop={false}
                      muted={false}
                    />
                    <Image className="at-col-close" src={annexClose} onClick={this.clickAnnexHandle.bind(this, false)}></Image>
                  </View>
                  : <View style="position: relative; min-height: {{touch.baseHeight}}px;">
                    <Image mode='aspectFit' src={annexShow.path}
                           onTouchStart={this.touchStartHandle.bind(this)}
                           onTouchMove={this.touchMoveHandle.bind(this)}
                           onTouchEnd={this.touchEndHandle.bind(this)}
                           onLoad={this.imgLoad.bind(this)}
                           style="position: absolute; width: {{touch.scaleWidth}}px; height: {{touch.scaleHeight}}px; top: {{touch.moveTop}}px; left: {{touch.moveLeft}}px;"></Image>
                    <Image className="at-col-close" src={annexClose} onClick={this.clickAnnexHandle.bind(this, false)}></Image>
                  </View>
                }
                </View>
              : <View className="annex__container"><Image className="at-col-img" mode='scaleToFill' src={blankImg}></Image></View>
            }
          </View>

        </View>
      )
    }
}

3.其他组件引用代码:

上传的时候调用:

import Cannex from '../../components/custom-annex/customAnnex' // 引入组件

cannex: any;
refCannex = (node) => { this.cannex = node };  // 获取组件实例

cannex.uploadAnnex({              // 调用组件上传方法
     formData: { 'xxx': 'xxx'},
     callFun: function () { }
});

<Cannex ref={this.refCannex} isUpload={true}></Cannex> // 组件视图

上传的时候效果:

微信小程序上传附件axios 微信小程序微附件_javascript

预览时候的调用:

import Cannex from '../../components/custom-annex/customAnnex' // 引入组件

<Cannex isUpload={false} idName='xxx' idValue={xxx} ></Cannex> // 预览加载组件

预览时候的效果:

微信小程序上传附件axios 微信小程序微附件_图像识别_02

注意:wx.chooseMedia在临时拍照上传时,文件可能为 .unknown 后缀,需要服务器端处理替换为.jpg处理。