整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

前端上传 大文件上传 如何做断点续传 分片上传 断点

前端上传 大文件上传 如何做断点续传 分片上传 断点续传

文件上传是什么

不管怎样简单的需求,在量级达到一定层次时,都会变得异常复杂

文件上传简单,文件变大就复杂

上传大文件时,以下几个变量会影响我们的用户体验

  • 服务器处理数据的能力
  • 请求超时
  • 网络波动

上传时间会变长,高频次文件上传失败,失败后又需要重新上传等等

为了解决上述问题,我们需要对大文件上传单独处理

这里涉及到分片上传及断点续传两个概念

分片上传

分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(Part)来进行分片上传

如下图

上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件

大致流程如下:

  1. 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
  2. 初始化一个分片上传任务,返回本次分片上传唯一标识;
  3. 按照一定的策略(串行或并行)发送各个分片数据块;
  4. 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件

断点续传

断点续传指的是在下载或上传时,将下载或上传任务人为的划分为几个部分

每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载。用户可以节省时间,提高速度

一般实现的方式有两种:

  • 服务器端返回,告知从哪开始
  • 浏览器端自行处理

上传过程中将文件在服务器上写为临时文件,等全部写完了(文件上传完),将此临时文件重命名为正式文件即可

如果中途上传中断过,下次上传的时候根据当前临时文件大小,作为在客户端读取文件的偏移量,从此位置继续读取文件数据块,上传到服务器从此偏移量继续写入文件即可

实现思路

个上传组件,需要具备的功能:

  • 需要校验文件格式
  • 可以上传任何文件,包括超大的视频文件(切片)
  • 上传期间断网后,再次联网可以继续上传(断点续传)
  • 要有进度条提示
  • 已经上传过同一个文件后,直接上传完成(秒传)

前后端分工:

前端:

  • 文件格式校验
  • 文件切片、md5计算
  • 发起检查请求,把当前文件的hash发送给服务端,检查是否有相同hash的文件
  • 上传进度计算
  • 上传完成后通知后端合并切片

后端:

  • 检查接收到的hash是否有相同的文件,并通知前端当前hash是否有未完成的上传
  • 接收切片
  • 合并所有切片

整体思路比较简单,拿到文件,保存文件唯一性标识,切割文件,分段上传,每次上传一段,根据唯一性标识判断文件上传进度,直到文件的全部片段上传完毕

下面的内容都是伪代码

读取文件内容:

const input=document.querySelector('input');
input.addEventListener('change', function() {
    var file=this.files[0];
});

可以使用md5实现文件的唯一性

const md5code=md5(file);

然后开始对文件进行分割

var reader=new FileReader();
reader.readAsArrayBuffer(file);
reader.addEventListener("load", function(e) {
    //每10M切割一段,这里只做一个切割演示,实际切割需要循环切割,
    var slice=e.target.result.slice(0, 10*1024*1024);
});

h5上传一个(一片)

const formdata=new FormData();
formdata.append('0', slice);
//这里是有一个坑的,部分设备无法获取文件名称,和文件类型,这个在最后给出解决方案
formdata.append('filename', file.filename);
var xhr=new XMLHttpRequest();
xhr.addEventListener('load', function() {
    //xhr.responseText
});
xhr.open('POST', '');
xhr.send(formdata);
xhr.addEventListener('progress', updateProgress);
xhr.upload.addEventListener('progress', updateProgress);

function updateProgress(event) {
    if (event.lengthComputable) {
        //进度条
    }
}

这里给出常见的图片和视频的文件类型判断

function checkFileType(type, file, back) {
/**
* type png jpg mp4 ...
* file input.change=> this.files[0]
* back callback(boolean)
*/
    var args=arguments;
    if (args.length !=3) {
        back(0);
    }
    var type=args[0]; // type='(png|jpg)' , 'png'
    var file=args[1];
    var back=typeof args[2]=='function' ? args[2] : function() {};
    if (file.type=='') {
        // 如果系统无法获取文件类型,则读取二进制流,对二进制进行解析文件类型
        var imgType=[
            'ff d8 ff', //jpg
            '89 50 4e', //png

            '0 0 0 14 66 74 79 70 69 73 6F 6D', //mp4
            '0 0 0 18 66 74 79 70 33 67 70 35', //mp4
            '0 0 0 0 66 74 79 70 33 67 70 35', //mp4
            '0 0 0 0 66 74 79 70 4D 53 4E 56', //mp4
            '0 0 0 0 66 74 79 70 69 73 6F 6D', //mp4

            '0 0 0 18 66 74 79 70 6D 70 34 32', //m4v
            '0 0 0 0 66 74 79 70 6D 70 34 32', //m4v

            '0 0 0 14 66 74 79 70 71 74 20 20', //mov
            '0 0 0 0 66 74 79 70 71 74 20 20', //mov
            '0 0 0 0 6D 6F 6F 76', //mov

            '4F 67 67 53 0 02', //ogg
            '1A 45 DF A3', //ogg

            '52 49 46 46 x x x x 41 56 49 20', //avi (RIFF fileSize fileType LIST)(52 49 46 46,DC 6C 57 09,41 56 49 20,4C 49 53 54)
        ];
        var typeName=[
            'jpg',
            'png',
            'mp4',
            'mp4',
            'mp4',
            'mp4',
            'mp4',
            'm4v',
            'm4v',
            'mov',
            'mov',
            'mov',
            'ogg',
            'ogg',
            'avi',
        ];
        var sliceSize=/png|jpg|jpeg/.test(type) ? 3 : 12;
        var reader=new FileReader();
        reader.readAsArrayBuffer(file);
        reader.addEventListener("load", function(e) {
            var slice=e.target.result.slice(0, sliceSize);
            reader=null;
            if (slice && slice.byteLength==sliceSize) {
                var view=new Uint8Array(slice);
                var arr=[];
                view.forEach(function(v) {
                    arr.push(v.toString(16));
                });
                view=null;
                var idx=arr.join(' ').indexOf(imgType);
                if (idx > -1) {
                    back(typeName[idx]);
                } else {
                    arr=arr.map(function(v) {
                        if (i > 3 && i < 8) {
                            return 'x';
                        }
                        return v;
                    });
                    var idx=arr.join(' ').indexOf(imgType);
                    if (idx > -1) {
                        back(typeName[idx]);
                    } else {
                        back(false);
                    }

                }
            } else {
                back(false);
            }

        });
    } else {
        var type=file.name.match(/\.(\w+)$/)[1];
        back(type);
    }
}

调用方法如下

checkFileType('(mov|mp4|avi)',file,function(fileType){
    // fileType=mp4,
    // 如果file的类型不在枚举之列,则返回false
});

上面上传文件的一步,可以改成:

formdata.append('filename', md5code+'.'+fileType);

有了切割上传后,也就有了文件唯一标识信息,断点续传变成了后台的一个小小的逻辑判断

后端主要做的内容为:根据前端传给后台的md5值,到服务器磁盘查找是否有之前未完成的文件合并信息(也就是未完成的半成品文件切片),取到之后根据上传切片的数量,返回数据告诉前端开始从第几节上传

如果想要暂停切片的上传,可以使用XMLHttpRequestabort方法

代码

<!DOCTYPE html>
<html lang="en">
 
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>文件上传</title>
  <script src="https://cdn.bootcss.com/axios/0.18.0/axios.min.js"></script>
  <script src="https://code.jquery.com/jquery-3.4.1.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.js"></script>
  <style>
    /* 自定义进度条样式 */
    .precent input[type=range] {
      -webkit-appearance: none;
      /*清除系统默认样式*/
      width: 7.8rem;
      /* background: -webkit-linear-gradient(#ddd, #ddd) no-repeat, #ddd; */
      /*设置左边颜色为#61bd12,右边颜色为#ddd*/
      background-size: 75% 100%;
      /*设置左右宽度比例*/
      height: 0.6rem;
      /*横条的高度*/
      border-radius: 0.4rem;
      border: 1px solid #ddd;
      box-shadow: 0 0 10px rgba(0,0,0,.125) inset ;
    }
 
    /*拖动块的样式*/
    .precent input[type=range]::-webkit-slider-thumb {
      -webkit-appearance: none;
      /*清除系统默认样式*/
      height: .9rem;
      /*拖动块高度*/
      width: .9rem;
      /*拖动块宽度*/
      background: #fff;
      /*拖动块背景*/
      border-radius: 50%;
      /*外观设置为圆形*/
      border: solid 1px #ddd;
      /*设置边框*/
    }
 
  </style>
</head>
 
<body>
  <h1>大文件分片上传测试</h1>
  <div>
    <input id="file" type="file" name="avatar" />
    <div style="padding: 10px 0;">
      <input id="submitBtn" type="button" value="提交" />
      <input id="pauseBtn" type="button" value="暂停" />
    </div>
    <div class="precent">
      <input type="range" value="0" /><span id="precentVal">0%</span>
    </div>
  </div>
  <script type="text/javascript" src="./js/index.js"></script>
</body>
 
</html>

大文件分片上传处理

$(document).ready(()=> {
  const submitBtn=$('#submitBtn');  //提交按钮
  const precentDom=$(".precent input")[0]; // 进度条
  const precentVal=$("#precentVal");  // 进度条值对应dom
  const pauseBtn=$('#pauseBtn');  // 暂停按钮
  // 每个chunk的大小,设置为1兆
  const chunkSize=1 * 1024 * 1024;
  // 获取slice方法,做兼容处理
  const blobSlice=File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
  // 对文件进行MD5加密(文件内容+文件标题形式)
  const hashFile=(file)=> {
    return new Promise((resolve, reject)=> {
      const chunks=Math.ceil(file.size / chunkSize);
      let currentChunk=0;
      const spark=new SparkMD5.ArrayBuffer();
      const fileReader=new FileReader();
      function loadNext() {
        const start=currentChunk * chunkSize;
        const end=start + chunkSize >=file.size ? file.size : start + chunkSize;
        fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
      }
      fileReader.onload=e=> {
        spark.append(e.target.result); // Append array buffer
        currentChunk +=1;
        if (currentChunk < chunks) {
          loadNext();
        } else {
          console.log('finished loading');
          const result=spark.end();
          // 通过内容和文件名称进行md5加密
          const sparkMd5=new SparkMD5();
          sparkMd5.append(result);
          sparkMd5.append(file.name);
          const hexHash=sparkMd5.end();
          resolve(hexHash);
        }
      };
      fileReader.onerror=()=> {
        console.warn('文件读取失败!');
      };
      loadNext();
    }).catch(err=> {
      console.log(err);
    });
  }
 
  // 提交
  submitBtn.on('click', async ()=> {
    var pauseStatus=false;
    var nowUploadNums=0
    // 1.读取文件
    const fileDom=$('#file')[0];
    const files=fileDom.files;
    const file=files[0];
    if (!file) {
      alert('没有获取文件');
      return;
    }
    // 2.设置分片参数属性、获取文件MD5值
    const hash=await hashFile(file); //文件 hash 
    const blockCount=Math.ceil(file.size / chunkSize); // 分片总数
    const axiosPromiseArray=[]; // axiosPromise数组
    // 文件上传
    const uploadFile=()=> {
      const start=nowUploadNums * chunkSize;
      const end=Math.min(file.size, start + chunkSize);
      // 构建表单
      const form=new FormData();
      // blobSlice.call(file, start, end)方法是用于进行文件分片
      form.append('file', blobSlice.call(file, start, end));
      form.append('index', nowUploadNums);
      form.append('hash', hash);
      // ajax提交 分片,此时 content-type 为 multipart/form-data
      const axiosOptions={
        onUploadProgress: e=> {
          nowUploadNums++;
          // 判断分片是否上传完成
          if (nowUploadNums < blockCount) {
            setPrecent(nowUploadNums, blockCount);
            uploadFile(nowUploadNums)
          } else {
            // 4.所有分片上传后,请求合并分片文件
            axios.all(axiosPromiseArray).then(()=> {
              setPrecent(blockCount, blockCount); // 全部上传完成
              axios.post('/file/merge_chunks', {
                name: file.name,
                total: blockCount,
                hash
              }).then(res=> {
                console.log(res.data, file);
                pauseStatus=false;
                alert('上传成功');
              }).catch(err=> {
                console.log(err);
              });
            });
          }
        },
      };
      // 加入到 Promise 数组中
      if (!pauseStatus) {
        axiosPromiseArray.push(axios.post('/file/upload', form, axiosOptions));
      }
 
    }
    // 设置进度条
    function setPrecent(now, total) {
      var prencentValue=((now / total) * 100).toFixed(2)
      precentDom.value=prencentValue
      precentVal.text(prencentValue + '%')
      precentDom.style.cssText=`background:-webkit-linear-gradient(top, #059CFA, #059CFA) 0% 0% / ${prencentValue}% 100% no-repeat`
    }
    // 暂停
    pauseBtn.on('click', (e)=> {
      pauseStatus=!pauseStatus;
      e.currentTarget.value=pauseStatus ? '开始' : '暂停'
      if (!pauseStatus) {
        uploadFile(nowUploadNums)
      }
    })
    uploadFile();
  });
})

文件上传和合并分片文件接口(node)

const Router=require('koa-router');
const multer=require('koa-multer');
const fs=require('fs-extra');
const path=require('path');
const router=new Router();

const { mkdirsSync }=require('../utils/dir');
const uploadPath=path.join(__dirname, 'upload');
const chunkUploadPath=path.join(uploadPath, 'temp');
const upload=multer({ dest: chunkUploadPath });

// 文件上传接口
router.post('/file/upload', upload.single('file'), async (ctx, next)=> {
  const { index, hash }=ctx.req.body;
  const chunksPath=path.join(chunkUploadPath, hash, '/');
  if(!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);
  fs.renameSync(ctx.req.file.path, chunksPath + hash + '-' + index);
  ctx.status=200;
  ctx.res.end('Success');
}) 
// 合并分片文件接口
router.post('/file/merge_chunks', async (ctx, next)=> {
  const { name, total, hash }=ctx.request.body;
  const chunksPath=path.join(chunkUploadPath, hash, '/');
  const filePath=path.join(uploadPath, name);
  // 读取所有的chunks
  const chunks=fs.readdirSync(chunksPath);
  // 创建存储文件
  fs.writeFileSync(filePath, ''); 
  if(chunks.length !==total || chunks.length===0) {
    ctx.status=200;
    ctx.res.end('切片文件数量不符合');
    return;
  }
  for (let i=0; i < total; i++) {
    // 追加写入到文件中
    fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));
    // 删除本次使用的chunk    
    fs.unlinkSync(chunksPath + hash + '-' +i);
  }
  fs.rmdirSync(chunksPath);
  // 文件合并成功,可以把文件信息进行入库。
  ctx.status=200;
  ctx.res.end('Success');
})

使用场景

  • 大文件加速上传:当文件大小超过预期大小时,使用分片上传可实现并行上传多个 Part, 以加快上传速度
  • 网络环境较差:建议使用分片上传。当出现上传失败的时候,仅需重传失败的Part
  • 流式上传:可以在需要上传的文件大小还不确定的情况下开始上传。这种场景在视频监控等行业应用中比较常见

小结

当前的伪代码,只是提供一个简单的思路,想要把事情做到极致,我们还需要考虑到更多场景,比如

  • 切片上传失败怎么办
  • 上传过程中刷新页面怎么办
  • 如何进行并行上传
  • 切片什么时候按数量切,什么时候按大小切
  • 如何结合 Web Worker 处理大文件上传
  • 如何实现秒传

人生又何尝不是如此,极致的人生体验有无限可能,越是后面才发现越是精彩 ~_~

给大家分享我收集整理的各种学习资料,前端小白交学习流程,入门教程等回答-下面是学习资料参考。

前端学习交流、自学、学习资料等推荐 - 知乎

html实现本地文件的上传,html实现文件上传,html实现文件上传解决方案,html实现文件上传思路,html实现文件上传实例,html实现文件上传源码,html实现文件分块上传,html实现文件分片上传,html实现文件夹上传,html实现文件加密上传,


要求操作便利,一次选择多个文件和文件夹进行上传;

支持PC端全平台操作系统,Windows,Linux,Mac

支持文件和文件夹的批量下载,断点续传。刷新页面后继续传输。关闭浏览器后保留进度信息。

支持文件夹批量上传下载,服务器端保留文件夹层级结构,服务器端文件夹层级结构与本地相同。

支持大文件批量上传(20G)和下载,同时需要保证上传期间用户电脑不出现卡死等体验;

支持文件夹上传,文件夹中的文件数量达到1万个以上,且包含层级结构。

支持断点续传,关闭浏览器或刷新浏览器后仍然能够保留进度。

支持文件夹结构管理,支持新建文件夹,支持文件夹目录导航

交互友好,能够及时反馈上传的进度;

服务端的安全性,不因上传文件功能导致JVM内存溢出影响其他功能使用;

最大限度利用网络上行带宽,提高上传速度;

对于大文件的处理,无论是用户端还是服务端,如果一次性进行读取发送、接收都是不可取,很容易导致内存问题。所以对于大文件上传,采用切块分段上传

从上传的效率来看,利用多线程并发上传能够达到最大效率。

文件上传页面的前端可以选择使用一些比较好用的上传组件,例如百度的开源组件WebUploader,这些组件基本能满足文件上传的一些日常所需功能,如异步上传文件,文件夹,拖拽式上传,黏贴上传,上传进度监控,文件缩略图,甚至是大文件断点续传,大文件秒传。

在web项目中上传文件夹现在已经成为了一个主流的需求。在OA,或者企业ERP系统中都有类似的需求。上传文件夹并且保留层级结构能够对用户行成很好的引导,用户使用起来也更方便。能够提供更高级的应用支撑。


1.下载示例

https://gitee.com/xproer/up6-vue-cli



将up6组件复制到项目中

示例中已经包含此目录



1.引入up6组件



2.配置接口地址

接口地址分别对应:文件初始化,文件数据上传,文件进度,文件上传完毕,文件删除,文件夹初始化,文件夹删除,文件列表

参考:http://www.ncmem.com/doc/view.aspx?id=e1f49f3e1d4742e19135e00bd41fa3de



3.处理事件



启动测试



启动成功



效果



数据库



源码工程文档:https://drive.weixin.qq.com/s?k=ACoAYgezAAw1dWofra

源码报价单:https://drive.weixin.qq.com/s?k=ACoAYgezAAwoiul8gl


OEM版报价单:https://drive.weixin.qq.com/s?k=ACoAYgezAAwuzp4W0a

产品源代码:https://drive.weixin.qq.com/s?k=ACoAYgezAAwbdKCskc
授权码生成器:https://drive.weixin.qq.com/s?k=ACoAYgezAAwTIcFph1

PRINGBOOT实现大文件分片上传的方法,SPRINGBOOT大文件上传、分片上传、断点续传、秒传的实现,SPRINGBOOT 整合 GRIDFS 、WEBUPLOADER实现大文件分块上传、断点续传、秒传,SPRINGBOOT实现大文件上传,断点续传,秒传功能,SPRINGBOOT 中大文件(分片上传)断点续传与极速秒传功能的实现,SPRINGBOOT实现文件的上传和下载,SPRINGMVC WEB项目大文件上传下载解决方案,

SPRINGBOOT+VUE实现多文件上传,SPRINGBOOT实现大文件上传/下载(分片、断点续传),SPRINGBOOT+webuploader实现多文件上传,SPRINGBOOT+js实现多文件上传,SPRINGBOOT+VUE实现多文件上传,网页实现文件夹上传断点续传,前端实现文件夹上传断点续传,js实现文件夹上传断点续传,JavaScript实现文件夹上传断点续传,vue实现文件夹上传断点续传,百度webuploader实现文件夹上传断点续传,webuploader实现文件夹上传断点续传,html5实现文件夹上传断点续传

jsp实现文件夹上传下载断点续传,jsp实现文件夹上传断点续传解决方案,jsp实现文件夹上传断点续传,JAVA 实现文件夹上传(SPRINGBOOT 框架),SpringBoot实现HTTP大文件断点续传分片下载,JAVA以HTTP方式实现大文件分片,分段,分块,分割下载。

SpringBoot主要是负责后端的业务逻辑和功能的实现。

客户那边是广州的一家公司,也是做IT项目的。实际上对具体的技术实际没有太大要求。

网上也考查和调研了一些组件,基本上都是调的HTML5的API,对HTML进行了一个基本的封闭,完全没有自己的核心技术,研发的同事说不考虑这些免费的方案,一方面是没有人维护,没有技术支持,遇到问题基本上没办法解决,另一方面就是可扩展性差,用户如果提了新需求也没办法来做扩展,没法满足。研发的同事说百度webuploader是免费坑人项目。连个人都找不到,领导说可以付费寻求技术支持,结果到他们官网找了半天才找到一个邮箱,发了邮件一年没人回,我也是醉了。这种服务态度和服务质量,谁还敢在政府项目中用啊。这不是自己给自己找不痛快吗?

需要支持断点续传,下载一半关闭电脑后,明天能够继续下载。或者关闭浏览器,或关闭网页,或刷新网页。

最好下载能够支持加密下载,在下载过程中数据是加密的,下载完后自动解密,主要是有安全需求。

速度这块的话,内网是希望跑满的,百兆网络的话12MB/S左右,千兆的话50MB/S左右。

需要支持文件夹下载,断点续传,下载保留层级结构。

网上搜到的SpringBoot的代码不多,完整的不多,能用的也不多,基本上大部分的文章只是提供了少量的代码,讲一下思路,或者实现方案。

之前一般的做法都是使用HTML5来做的,大部都是传文件的,传文件夹的不多。网上能够搜到的能用的不多。下来下的话,基本上都不能满足用户的 需求。或者用户在用的时候总是会遇到这样或那样的问题,维护的话也很麻烦,用户满意度比较低。

公司有自已的产品,也是在我们自己的产品中集成这个功能,给客户用,客户还是比较多的,我们是做的行业软件客户每年都有一千多个。操作系统比较多,终端系统不统一,研发部门的同事用Windows,macOS多一些,后端运维同事用Linux多一些,主要就是centos和ubuntu,客户那边政府部门用信创国产化的多一些,有中标麒麟,银河麒麟,统信UOS,龙芯,华为鲲鹏

我们主要是做政府项目,客户也都是政府单位的,对用户体验要求比较高,要让他们感觉用的方便,对稳定性要求比较高,基本上一年365天都不希望你出问题,对安全性要求也比较高,涉密了,信创国产化,不能连外网的,都是内网。兼容性要求比较高,有用WIN7+IE8的,也要兼容。

领导要求必须要提供技术支持,长期技术支持服务,长期的产品更新和维护服务,因为我们是做产品为主,给到客户那边也是长期维护,不是一锤子买卖,一般合作过都是5~10年的长期合作,客户和领导都非常重视这一块。

合作过程中也可能出现新的需求,或者二次开发,或者定制开发需求。需要满足客户需求,客户那边是什么环境我们必须要支持。

实际上核心也就是要求稳定,兼容性,可扩展性强,我们做的行业软件,不是互联网项目,不会经常变化,客户那边每天工作都是要用我们的产品。所以对产品稳定性要求很高。版本:6.5.40
代码:https://gitee.com/xproer/up6-jsp-springboot/tree/6.5.40/

nosql示例

nosql示例不需要进行任何配置,可以直接访问测试。

SQL示例

1.创建数据库

2.配置数据库连接

3.自动下载maven依赖

4.启动项目

启动成功

6.访问及测试

默认页面接口定义:

在浏览器中访问:

数据表中的数据

相关问题:

1.javax.servlet.http.HttpServlet错误

2.项目无法发布到tomcat

3.md5计算完毕后卡住

4.服务器找不到config.json文件

5.Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.8.1:compile

相关参考:

文件保存位置

源码工程文档:https://drive.weixin.qq.com/s?k=ACoAYgezAAw1dWofra

源码报价单:https://drive.weixin.qq.com/s?k=ACoAYgezAAwoiul8gl

OEM版报价单:https://drive.weixin.qq.com/s?k=ACoAYgezAAwuzp4W0a

控件源码下载:https://drive.weixin.qq.com/s?k=ACoAYgezAAwbdKCskc