[
  {
    "path": ".editorconfig",
    "content": "[*.{js,jsx,ts,tsx,vue}]\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\napp/run\nserver/app/public/\nserver/app/run/\nserver/run/\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript v1 declaration files\ntypings/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n.env.test\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n\n# Next.js build output\n.next\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and *not* Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 woniuppp\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# file-upload\n面试造火箭系列\n\n\n 1. vue create client\n 2. cd client && vue add element\n 3. mkdir server\n 4. npm install egg egg-bin --save"
  },
  {
    "path": "client/.gitignore",
    "content": ".DS_Store\nnode_modules\n/dist\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "client/README.md",
    "content": "# client\n\n## Project setup\n```\nnpm install\n```\n\n### Compiles and hot-reloads for development\n```\nnpm run serve\n```\n\n### Compiles and minifies for production\n```\nnpm run build\n```\n\n### Run your tests\n```\nnpm run test\n```\n\n### Lints and fixes files\n```\nnpm run lint\n```\n\n### Customize configuration\nSee [Configuration Reference](https://cli.vuejs.org/config/).\n"
  },
  {
    "path": "client/babel.config.js",
    "content": "module.exports = {\n  presets: [\n    '@vue/cli-plugin-babel/preset'\n  ]\n}\n"
  },
  {
    "path": "client/package.json",
    "content": "{\n  \"name\": \"client\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"serve\": \"vue-cli-service serve\",\n    \"build\": \"vue-cli-service build\",\n    \"lint\": \"vue-cli-service lint\",\n    \"start\": \"npm run serve\"\n  },\n  \"dependencies\": {\n    \"axios\": \"^0.19.2\",\n    \"blessed\": \"^0.1.81\",\n    \"blessed-contrib\": \"^4.8.18\",\n    \"core-js\": \"^3.6.4\",\n    \"element-ui\": \"^2.4.5\",\n    \"marked\": \"^0.8.0\",\n    \"spark-md5\": \"^3.0.0\",\n    \"stylus\": \"^0.54.7\",\n    \"stylus-loader\": \"^3.0.2\",\n    \"vue\": \"^2.6.11\"\n  },\n  \"devDependencies\": {\n    \"@vue/cli-plugin-babel\": \"^4.2.0\",\n    \"@vue/cli-plugin-eslint\": \"^4.2.0\",\n    \"@vue/cli-service\": \"^4.2.0\",\n    \"babel-eslint\": \"^10.0.3\",\n    \"eslint\": \"^6.7.2\",\n    \"eslint-plugin-vue\": \"^6.1.2\",\n    \"vue-cli-plugin-element\": \"^1.0.1\",\n    \"vue-template-compiler\": \"^2.6.11\"\n  },\n  \"eslintConfig\": {\n    \"root\": true,\n    \"env\": {\n      \"node\": true\n    },\n    \"extends\": [\n      \"plugin:vue/essential\"\n    ],\n    \"parserOptions\": {\n      \"parser\": \"babel-eslint\"\n    },\n    \"rules\": {}\n  },\n  \"browserslist\": [\n    \"> 1%\",\n    \"last 2 versions\"\n  ]\n}\n"
  },
  {
    "path": "client/public/hash.js",
    "content": "\n\n// web-worker\nself.importScripts('spark-md5.min.js')\n\nself.onmessage = e=>{\n    // 接受主线程的通知\n    const {chunks} = e.data\n    const spark = new self.SparkMD5.ArrayBuffer()\n    let progress = 0\n    let count = 0\n    \n    const loadNext = index=>{\n        const reader = new FileReader()\n        reader.readAsArrayBuffer(chunks[index].file)\n        reader.onload = e=>{\n            // 累加器 不能依赖index，\n            count++\n            // 增量计算md5\n            spark.append(e.target.result)\n            if(count===chunks.length){\n                // 通知主线程，计算结束\n                self.postMessage({\n                    progress:100,\n                    hash:spark.end()\n                })\n            }else{\n                // 每个区块计算结束，通知进度即可\n                progress += 100/chunks.length\n                self.postMessage({\n                    progress\n                })\n                // 计算下一个\n                loadNext(count)\n            }\n        }\n    }\n    // 启动\n    loadNext(0)\n\n\n}"
  },
  {
    "path": "client/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n    <link rel=\"icon\" href=\"<%= BASE_URL %>favicon.ico\">\n    <title><%= htmlWebpackPlugin.options.title %></title>\n  </head>\n  <body>\n    <noscript>\n      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>\n    </noscript>\n    <div id=\"app\"></div>\n    <!-- built files will be auto injected -->\n  </body>\n</html>\n"
  },
  {
    "path": "client/src/App.vue",
    "content": "<template>\n  <div class=\"app\">\n\n    <input type=\"text\">\n    <i class=\"el-icon-loading\" style=\"color:#F56C6C;\"></i>\n\n    <!-- <form method=\"post\" action=\"http://localhost:7001/upload\" enctype=\"multipart/form-data\"> -->\n    <div ref=\"drag\" id=\"drag\">\n      <input type=\"file\" name=\"file\" @change=\"handleFileChange\" />\n      <!-- <img :src=\"preview\" alt=\"\"> -->\n    </div>\n    <!-- <div v-loading=\"loading\">\n      <textarea ref=\"article\" v-model=\"article\" cols=\"30\" rows=\"10\"></textarea>\n      <div class=\"output\" v-html=\"articleHtml\"></div>\n    </div>\n    <div v-if=\"file\">{{file.name}}</div> -->\n\n    <div>\n      上传进度\n    </div>\n      <el-progress :text-inside=\"true\" :stroke-width=\"20\" :percentage=\"uploadProgress\"></el-progress>\n\n          <div>文件准备中</div>\n          <div>\n      <el-progress :text-inside=\"true\" :stroke-width=\"20\" :percentage=\"hashProgress\"></el-progress>\n\n          </div>\n      {{hashProgress}}\n    <div> \n      <el-button type=\"primary\" @click=\"handleUpload\">上 传</el-button>\n    </div>\n        <div> \n      <el-button type=\"primary\" @click=\"handleUpload1\">慢启动上传</el-button>\n    </div>\n\n\n\n\n\n    <!-- 方块进度条 -->\n\n      <div class=\"cube-container\" :style=\"{width:cubeWidth+'px'}\">\n        <div class=\"cube\" \n\n          v-for=\"chunk in chunks\" \n          :key=\"chunk.name\">\n          <div           \n            :class=\"{\n            'uploading':chunk.progress>0&&chunk.progress<100, \n            'success':chunk.progress==100,\n            'error':chunk.progress<0,\n            }\" \n            :style=\"{height:chunk.progress+'%'}\"\n            >\n            <i v-if=\"chunk.progress<100 &&chunk.progress>1\" class=\"el-icon-loading\" style=\"color:#F56C6C;\"></i>\n          </div>\n        </div>\n      </div>\n\n  </div>\n</template>\n\n<script>\nimport marked from 'marked'\nimport sparkMd5 from 'spark-md5'\nconst CHUNK_SIZE = 1*1024*1024 // 1M\nconst IMG_WIDTH_LIMIT = 1000\nconst IMG_HEIGHT_LIMIT = 1000\nexport default {\n  name: \"app\",\n  data() {\n    return {\n      chunks:[],\n      file: null,\n      hash:null,\n      preview:null,\n      article:`# 蜗牛老湿开心的一天\n      * 吃饭\n      * 睡觉\n      * 上王者\n`,\n      loading:false,\n      hashProgress:0\n    };\n  },\n  computed:{\n    articleHtml(){\n      return marked(this.article)\n    },\n    cubeWidth(){\n      return Math.ceil(Math.sqrt(this.chunks.length))*16\n    },\n    uploadProgress() {\n      if (!this.file || !this.chunks.length) return 0;\n      const loaded = this.chunks\n        .map(item => item.chunk.size * item.progress)\n        .reduce((acc, cur) => acc + cur);\n      return parseInt((loaded / this.file.size).toFixed(2));\n    }\n  },\n  async mounted() {\n    // this.bindDragEvent('drag',()=>{\n    //   this.preview = window.URL.createObjectURL(this.file)\n\n    // })\n    // this.bindDragEvent('article',async ()=>{\n    //     this.loading = true\n    //     const ret = await this.handleUpload()\n    //     this.article += `![${this.file.name}](/api${ret.url})`\n    //     this.loading = false\n\n    // })\n    // this.bindPasteEvent()\n  },\n  methods: {\n    bindPasteEvent(){\n      this.$refs.article.addEventListener('paste',async e=>{\n        const files = e.clipboardData.files\n        if(!files.length) return \n        this.file = files[0]\n        this.loading = true\n        const ret = await this.handleUpload()\n        this.article += `![${this.file.name}](/api${ret.url})`\n        this.loading = false\n\n        e.preventDefault()\n      })\n    },\n    bindDragEvent(name,cb) {\n      const drag = this.$refs[name]\n\n      drag.addEventListener(\"dragover\", e => {\n        drag.style.borderColor = \"red\"\n        e.preventDefault()\n      })\n      drag.addEventListener(\"dragleave\", e => {\n        drag.style.borderColor = \"#eee\"\n        e.preventDefault()\n      })\n      drag.addEventListener(\"drop\", e => {\n          const fileList = e.dataTransfer.files\n          drag.style.borderColor = \"#eee\"\n          this.file = fileList[0]; // 先只考虑单文件\n          cb && cb()\n          e.preventDefault()\n        },\n        false)\n    },\n\n    handleFileChange(e) {\n      const [file] = e.target.files;\n      if (!file) return;\n     \n      // if(file.size>CHUNK_SIZE){\n      //   this.$message.error(\"请选择小于2M的文件\");\n      //   return;\n      // }\n      // if(!this.isImage(file)){\n      //   this.$message.error(\"请选择正确的图片格式\");\n      //   return \n      // }\n\n      this.file = file;\n    },\n    async blobToData(blob){\n      return new Promise(resolve=>{\n        const reader = new FileReader()\n        reader.onload = function () {\n          resolve(reader.result)\n        }\n        reader.readAsBinaryString(blob)\n      })\n      // 二进制=》ascii码=》转成16进制字符串\n    },\n    async blobToString(blob){\n      return new Promise(resolve=>{\n        const reader = new FileReader()\n        reader.onload = function () {\n          const ret = reader.result.split('')\n                        .map(v=>v.charCodeAt())\n                        .map(v=>v.toString(16).toUpperCase())\n                        .map(v=>v.padStart(2,'0'))\n                        .join(' ')\n          resolve(ret)\n        }\n        reader.readAsBinaryString(blob)\n      })\n      // 二进制=》ascii码=》转成16进制字符串\n    },\n    async getRectByOffset(file,widthOffset,heightOffset,reverse){\n      let width = await this.blobToString(file.slice(...widthOffset))\n      let height = await this.blobToString(file.slice(...heightOffset))\n\n      if(reverse){\n        // 比如gif 的宽，6和7 是反着排的 大小端存储\n        // 比如6位仕89，7位仕02， gif就是 0289 而不是8920的值 切分后翻转一下\n        width = [width.slice(3,5),width.slice(0,2)].join(' ')\n        height = [height.slice(3,5),height.slice(0,2)].join(' ')\n      }\n      const w = parseInt(width.replace(' ', ''),16)\n      const h = parseInt(height.replace(' ', ''),16)\n      return {w,h}\n    },\n    async isGif(file){\n      const ret = await this.blobToString(file.slice(0,6))\n      const isgif = (ret==='47 49 46 38 39 61') || (ret==='47 49 46 38 37 61')\n      if(isgif){\n        console.log('文件是gif')\n\n        const {w,h} = await this.getRectByOffset(file,[6,8],[8,10],true)\n        console.log('gif宽高',w,h)\n        if(w>IMG_WIDTH_LIMIT || h>IMG_HEIGHT_LIMIT){\n          this.$message.error(\"gif图片宽高不得超过！\"+IMG_WIDTH_LIMIT+'和'+IMG_HEIGHT_LIMIT);\n          return false\n        }\n\n      }\n      return isgif\n      // 文件头16进制 47 49 46 38 39 61 或者47 49 46 38 37 61\n      // 分别仕89年和87年的规范\n      // const tmp = '47 49 46 38 39 61'.split(' ')\n      //               .map(v=>parseInt(v,16))\n      //               .map(v=>String.fromCharCode(v))\n      // console.log('gif头信息',tmp)\n      // // 或者把字符串转为16进制 两个方法用那个都行\n      // const tmp1 = 'GIF89a'.split('')\n      //                 .map(v=>v.charCodeAt())\n      //                 .map(v=>v.toString(16))\n      // console.log('gif头信息',tmp1)\n      \n      // return ret ==='GIF89a' || ret==='GIF87a'\n      // 文件头标识 (6 bytes) 47 49 46 38 39(37) 61\n\n    },\n    async isPng(file){\n      const ret = await this.blobToString(file.slice(0,8))\n      const ispng = ret==='89 50 4E 47 0D 0A 1A 0A'\n      if(ispng){\n        console.log('png宽高',w,h)\n        const {w,h} = await this.getRectByOffset(file,[18,20],[22,24])\n        if(w>IMG_WIDTH_LIMIT || h>IMG_HEIGHT_LIMIT){\n          this.$message.error(\"png图片宽高不得超过！\"+IMG_WIDTH_LIMIT+'和'+IMG_HEIGHT_LIMIT);\n          return false\n        }\n      }\n      return ispng\n    },\n    async isJpg(file){\n      // jpg开头两个仕 FF D8\n      // 结尾两个仕 FF D9\n      const len = file.size\n      const start = await this.blobToString(file.slice(0,2))\n      const tail = await this.blobToString(file.slice(-2,len))\n      const isjpg = start==='FF D8' && tail==='FF D9'\n      if(isjpg){\n        const heightStart = parseInt('A3',16)\n        const widthStart = parseInt('A5',16)\n        const {w,h} = await this.getRectByOffset(file,[widthStart,widthStart+2],[heightStart,heightStart+2])\n        console.log('jpg大小',w, h)\n      }\n      return isjpg\n\n    },\n    isImage(file){\n      return this.isGif(file) && this.isPng(file) && this.isJpg(file)\n\n    },\n    createFileChunk(file,size=CHUNK_SIZE){\n      // 生成文件块 Blob.slice语法\n      const chunks = [];\n      let cur = 0;\n      while (cur < file.size) {\n        chunks.push({index:cur, file: file.slice(cur, cur + size)});\n        cur += size;\n      }\n      return chunks;\n    },\n    ext(filename){\n      // 返回文件后缀名\n      return filename.split('.').pop()\n    },\n    async calculateHash(file){\n      // 直接计算md5 大文件会卡顿\n      const ret = await this.blobToData(file)\n      return sparkMd5.hash(ret)\n    },\n    // web-worker\n    async calculateHashWorker(chunks) {\n      return new Promise(resolve => {\n        // web-worker 防止卡顿主线程\n        this.worker = new Worker(\"/hash.js\");\n        this.worker.postMessage({ chunks });\n        this.worker.onmessage = e => {\n          const { progress, hash } = e.data;\n          this.hashProgress = Number(progress.toFixed(2));\n          if (hash) {\n            resolve(hash);\n          }\n        };\n      });\n    },\n    async calculateHashSample() {\n      return new Promise(resolve => {\n        const spark = new sparkMd5.ArrayBuffer();\n        const reader = new FileReader();\n        const file = this.file;\n        // 文件大小\n        const size = this.file.size;\n        let offset = 2 * 1024 * 1024;\n\n        let chunks = [file.slice(0, offset)];\n\n        // 前面100K\n\n        let cur = offset;\n        while (cur < size) {\n          // 最后一块全部加进来\n          if (cur + offset >= size) {\n            chunks.push(file.slice(cur, cur + offset));\n          } else {\n            // 中间的 前中后去两个字节\n            const mid = cur + offset / 2;\n            const end = cur + offset;\n            chunks.push(file.slice(cur, cur + 2));\n            chunks.push(file.slice(mid, mid + 2));\n            chunks.push(file.slice(end - 2, end));\n          }\n          // 前取两个子杰\n          cur += offset;\n        }\n        // 拼接\n        reader.readAsArrayBuffer(new Blob(chunks));\n\n        // 最后100K\n        reader.onload = e => {\n          spark.append(e.target.result);\n          this.hashProgress = 100\n          resolve(spark.end());\n        };\n      });\n    },\n    async calculateHashIdle(chunks) {\n      return new Promise(resolve => {\n        const spark = new sparkMd5.ArrayBuffer();\n        let count = 0;\n        const appendToSpark = async file => {\n          return new Promise(resolve => {\n            const reader = new FileReader();\n            reader.readAsArrayBuffer(file);\n            reader.onload = e => {\n              spark.append(e.target.result);\n              resolve();\n            };\n          });\n        };\n        const workLoop = async deadline => {\n          // 有任务，并且当前帧还没结束\n          while (count < chunks.length && deadline.timeRemaining() > 1) {\n            await appendToSpark(chunks[count].file);\n            count++;\n            // 没有了 计算完毕\n            if (count < chunks.length) {\n              // 计算中\n              this.hashProgress = Number(\n                ((100 * count) / chunks.length).toFixed(2)\n              );\n              // console.log(this.hashProgress)\n            } else {\n              // 计算完毕\n              this.hashProgress = 100;\n              resolve(spark.end());\n            }\n          }\n          console.log(`浏览器有任务拉，开始计算${count}个，等待下次浏览器空闲`)\n\n          window.requestIdleCallback(workLoop);\n        };\n        window.requestIdleCallback(workLoop);\n      });\n    },\n    async handleUpload1(){\n      // @todo数据缩放的比率 可以更平缓  \n      // @todo 并发+慢启动\n\n      // 慢启动上传逻辑 \n      const file = this.file\n      if (!file) return;\n      const fileSize = file.size\n      let offset = 0.1*1024*1024 \n\n      let cur = 0 \n      let count =0\n      this.hash = await this.calculateHashSample();\n\n      while(cur<fileSize){\n        const chunk = file.slice(cur, cur+offset)\n        cur+=offset\n        const chunkName = this.container.hash + \"-\" + count;\n        const form = new FormData();\n\n          form.append(\"chunkname\", chunkName)\n          form.append(\"ext\", this.ext(this.file.name))\n          form.append(\"hash\", this.hash)\n          // form.append(\"file\", new File([chunk],name,{hash,type:'png'}))\n\n\n        let start = new Date().getTime()\n        await this.$axios.post( '/upload', form)\n        const now = new Date().getTime()\n\n        const time = ((now -start)/1000).toFixed(4)\n\n        // 期望10秒一个切片\n        let rate = time/10\n        // 速率有最大和最小 可以考虑更平滑的过滤 比如1/tan \n        if(rate<0.5) rate=0.5\n        if(rate>2) rate=2\n        // 新的切片大小等比变化\n        console.log(`切片${count}大小是${this.format(offset)},耗时${time}秒，是30秒的${rate}倍，修正大小为${this.format(offset/rate)}`)\n        offset = parseInt(offset/rate)\n        // if(time)\n        count++\n      }\n\n\n\n    },\n\n    // async calculateHash\n    async handleUpload() {\n      if (!this.file) {\n        this.$message.info(\"请选择文件\");\n        return;\n      }\n      let chunks = this.createFileChunk(this.file);\n\n      // 计算hash 文件指纹标识\n      // this.hash = await this.calculateHash(this.file)\n      // web-worker\n      // this.hash = await this.calculateHashWorker(chunks)\n      // requestIdleCallback\n      // this.hash = await this.calculateHashIdle(chunks)\n      \n      // 抽样哈希，牺牲一定的准确率 换来效率，hash一样的不一定是同一个文件， 但是不一样的一定不是 \n      // 所以可以考虑用来预判\n      this.hash = await this.calculateHashSample()\n\n      // 检查文件是否已经上传\n      const { uploaded, uploadedList } = await this.$axios.post('/check',{\n          ext:this.ext(this.file.name),\n          hash:this.hash\n        }\n      )\n      if(uploaded){\n        return this.$message.success(\"秒传:上传成功\")\n      }\n      // 切片\n\n      this.chunks = chunks.map((chunk,index)=>{\n        // 每一个切片的名字\n        const chunkName = this.hash+'-'+index\n        return {\n          hash:this.hash,\n          chunk:chunk.file,\n          name:chunkName,\n          index,\n          // 设置进度条\n          progress: uploadedList.indexOf(chunkName) > -1 ? 100 : 0,\n        }\n      })\n      // 传入已经存在的切片清单\n      await this.uploadChunks(uploadedList);\n\n    },\n    async mergeRequest(){\n      await this.$axios.post(\"/merge\", {\n        ext: this.ext(this.file.name),\n        size: CHUNK_SIZE,\n        hash: this.hash\n      });\n    },\n    sendRequest(chunks, limit=4){\n      return new Promise((resolve,reject)=>{\n        const len = chunks.length\n        let counter = 0\n        // 全局开关\n        let isStop = false \n\n\n        const start = async ()=>{\n\n          if(isStop){\n            return \n          }\n          const task = chunks.shift()\n          if(task){\n            const {form,index} = task\n            try{\n              await this.$axios.post('/upload',form, {\n                onUploadProgress: progress => {\n                  this.chunks[index].progress = Number(((progress.loaded / progress.total) * 100).toFixed(2));\n                }\n              })\n              if(counter==len-1){\n                // 最后一个\n                resolve()\n              }else{\n                counter++\n                start()\n              }\n            }catch(e){\n              // 当前切片报错了\n              // 尝试3次重试机制，重新push到数组中\n              console.log('出错了')\n              // 进度条改成红色\n              this.chunks[index].progress = -1\n              if(task.error<3){\n                task.error++\n                // 队首进去 准备重试\n                chunks.unshift(task)\n                start()\n              }else{\n                // 错误3次了 直接结束\n                isStop=true\n                reject()\n              }\n            }\n\n          }\n        }\n\n        while(limit>0){\n          setTimeout(()=>{\n            // 模拟延迟\n            start()\n          }, Math.random()*2000)\n\n          limit-=1\n        }\n\n\n\n      })\n\n    },\n    async uploadChunks(uploadedList=[]){\n      const list = this.chunks\n        .filter(chunk => uploadedList.indexOf(chunk.name) == -1)\n        .map(({ chunk,name, hash, index }, i) => {\n          const form = new FormData();\n          form.append(\"chunkname\", name)\n          form.append(\"ext\", this.ext(this.file.name))\n          form.append(\"hash\", hash)\n          // form.append(\"file\", new File([chunk],name,{hash,type:'png'}))\n          form.append(\"file\",chunk)\n\n          return { form, index,error:0}\n        })\n      //   .map(({ form, index }) =>this.$axios.post('/upload',form, {\n      //     onUploadProgress: progress => {\n      //       this.chunks[index].progress = Number(((progress.loaded / progress.total) * 100).toFixed(2));\n      //     }\n      //   }))\n      // await Promise.all(list);\n      try{\n        await this.sendRequest([...list],4)\n        if(uploadedList.length + list.length === this.chunks.length){\n          await this.mergeRequest()\n        }\n      }catch(e){\n        this.$message.error('上传似乎除了点小问题，重试试试哈')\n      }\n\n    }\n  }\n};\n</script>\n\n<style lang=\"stylus\">\n.app>div \n  margin 50px\n#drag \n  height 100px\n  border 2px dashed #eee\n  line-height 100px\n  text-align center\n  vertical-align middle\nimg\n  width 50px\n.output\n  display inline-block\n  vertical-align top\n  margin-left 30px\n  padding 10px\n  width 300px\n  background  #eee\n  img \n    width 200px\n\n.cube-container\n  width 100px\n  overflow hidden\n.cube\n  width 14px\n  height 14px\n  line-height 12px;\n  border 1px solid black\n  background  #eee\n  float left\n  >.success\n    background #67C23A\n  >.uploading\n    background #409EFF\n  >.error\n    background #F56C6C\n\n</style>\n"
  },
  {
    "path": "client/src/components/HelloWorld.vue",
    "content": "<template>\n  <div class=\"hello\">\n    <h1>{{ name }}</h1>\n    <hr>\n    <h1>{{ type }}</h1>\n        <hr>\n    <h1>{{ age }}</h1>\n  </div>\n</template>\n\n<script>\nexport default {\n  name: 'HelloWorld',\n  props:['name','type','age','type:age'],\n  mounted(){\n    console.log(this['type:age'])\n  }\n}\n</script>\n\n<!-- Add \"scoped\" attribute to limit CSS to this component only -->\n<style scoped>\nh3 {\n  margin: 40px 0 0;\n}\nul {\n  list-style-type: none;\n  padding: 0;\n}\nli {\n  display: inline-block;\n  margin: 0 10px;\n}\na {\n  color: #42b983;\n}\n</style>\n"
  },
  {
    "path": "client/src/main.js",
    "content": "import Vue from 'vue'\nimport App from './App.vue'\nimport axios from 'axios'\nimport './plugins/element.js'\n\nlet request = axios.create({\n  baseURL:\"/api\"\n})\n\n\nrequest.interceptors.response.use(\n  async response=>{\n    // header config这里处理就可以了，应用层只需要数据data\n    let {data} = response\n    // if(dat)\n    return data\n\n  }\n)\nVue.prototype.$axios = request\nVue.config.productionTip = false\n\nnew Vue({\n  render: h => h(App),\n}).$mount('#app')\n"
  },
  {
    "path": "client/src/plugins/element.js",
    "content": "import Vue from 'vue'\nimport Element from 'element-ui'\nimport 'element-ui/lib/theme-chalk/index.css'\n\nVue.use(Element)\n"
  },
  {
    "path": "client/vue.config.js",
    "content": "module.exports = {\n  devServer: {\n    proxy:{\n      '/api/':{\n        target:'http://localhost:7001',\n        secure:false,\n        pathRewrite:{\n          '^/api':''\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "index.html",
    "content": "<style>\n    .water-waves {\n      margin: 0 auto;\n      overflow: hidden;\n      position: relative;\n      width: 100px;\n      height: 100px;\n      border-radius: 50%;\n      border: 1px solid silver;\n      text-align: center;\n      line-height: 50px;\n      animation: water-waves linear infinite;\n    }\n  \n    .water-wave1 {\n      position: absolute;\n      top: 40%;\n      left: -25%;\n      background: #33cfff;\n      opacity: 0.7;\n      width: 200%;\n      height: 200%;\n      border-radius: 40%;\n      animation: inherit;\n      animation-duration: 5s;\n    }\n  \n    .water-wave2 {\n      position: absolute;\n      top: 45%;\n      left: -35%;\n      background: #0eaffe;;\n      opacity: 0.5;\n      width: 200%;\n      height: 200%;\n      border-radius: 35%;\n      animation: inherit;\n      animation-duration: 7s;\n    }\n  \n    .water-wave3 {\n      position: absolute;\n      top: 50%;\n      left: -35%;\n      background: #0f7ae4;\n      opacity: 0.3;\n      width: 200%;\n      height: 200%;\n      border-radius: 33%;\n      animation: inherit;\n      animation-duration: 11s;\n    }\n  \n    @keyframes water-waves {\n      0% {\n        transform: rotate(0deg);\n      }\n      100% {\n        transform: rotate(360deg);\n      }\n    }\n  </style>\n  \n  <div class=\"water-waves\">\n    <div class=\"water-wave1\"></div>\n    <div class=\"water-wave2\"></div>\n    <div class=\"water-wave3\"></div>\n    水波效果\n  </div>"
  },
  {
    "path": "server/app/controller/home.js",
    "content": "// app/controller/home.js\nconst path = require('path')\nconst fse = require(\"fs-extra\")\n\nconst Controller = require('egg').Controller;\n\nclass HomeController extends Controller {\n  async index() {\n    this.ctx.body = {\n      msg:'hello eggjs'\n    }\n  }\n  async merge(){\n    const {ext,size,hash} = this.ctx.request.body\n    const filePath = path.resolve(this.config.UPLOAD_DIR, `${hash}.${ext}`)\n    await this.ctx.service.upload.mergeFileChunk(filePath, hash, size)\n    this.ctx.body = {\n      code:0,\n      msg:'合并成功'\n    }\n\n  }\n  async getUploadedList(dirPath){\n    return fse.existsSync(dirPath) \n      ? (await fse.readdir(dirPath)).filter(name=>name[0]!=='.') // 过滤诡异的隐藏文件 比如.DS_store\n      : []\n  }\n  async check(){\n    const { ext, hash } = this.ctx.request.body\n    const filePath = path.resolve(this.config.UPLOAD_DIR, `${hash}.${ext}`)\n    console.log(filePath)\n    // 文件是否存在\n    let uploaded = false\n    let uploadedList = []\n    if (fse.existsSync(filePath)) {\n      // 存在文件，直接返回已上传\n      uploaded = true\n    }else{\n      // 文件没有完全上传完毕，但是可能存在部分切片上传完毕了\n      uploadedList = await this.getUploadedList(path.resolve(this.config.UPLOAD_DIR, hash))\n    }\n\n    this.ctx.body = {\n      code:0,\n      uploaded,\n      uploadedList // 过滤诡异的隐藏文件\n    }\n  } \n  async upload(){\n    const { ctx } = this\n    if(Math.random()<0.5){\n      // 随机报个错\n      return ctx.status = 500;\n    }\n    const file = ctx.request.files[0]\n    const {chunkname,ext,hash} = ctx.request.body\n    console.log(file,hash,chunkname,ext)\n    const filename = `${hash}.${ext}`\n    // 最终文件存储位置 根据chunkname获取后缀，名字用hash\n    const filePath = path.resolve(\n      this.config.UPLOAD_DIR,\n      filename\n    )\n    // 碎片文件夹，用hash命名\n    const chunkPath = path.resolve(this.config.UPLOAD_DIR, hash )\n\n    // 文件存在直接返回\n    if (fse.existsSync(filePath)) {\n      this.ctx.body = {\n        code:-1,\n        msg:'文件存在',\n        url:`/public/${filename}`\n      }\n      return\n    }\n    if (!fse.existsSync(this.config.UPLOAD_DIR)) {\n      await fse.mkdirs(this.config.UPLOAD_DIR)\n    }\n    await fse.move(file.filepath, `${chunkPath}/${chunkname}`)\n    this.ctx.body={\n      code:0,\n      msg:'上传成功',\n      url:`/public/${filename}`\n    }\n\n\n  }\n}\n\nmodule.exports = HomeController;"
  },
  {
    "path": "server/app/router.js",
    "content": "// app/router.js\nmodule.exports = app => {\n  const { router, controller } = app;\n  router.get('/index', controller.home.index);\n  router.post('/upload', controller.home.upload);\n  router.post('/merge', controller.home.merge);\n  router.post('/check', controller.home.check);\n};"
  },
  {
    "path": "server/app/service/upload.js",
    "content": "// app/service/upload.js\nconst path = require('path')\nconst fse = require('fs-extra')\nconst Service = require('egg').Service;\n\nclass UploadService extends Service {\n\n  extractExt(filename) {\n    return filename.slice(filename.lastIndexOf(\".\"), filename.length)\n  }\n  async mergeFiles(files, dest, size) {\n    const pipeStream = (filePath, writeStream) =>\n      new Promise(resolve => {\n        const readStream = fse.createReadStream(filePath)\n        readStream.on(\"end\", () => {\n          // 删除文件\n          fse.unlinkSync(filePath)\n          resolve()\n        })\n        readStream.pipe(writeStream)\n      })\n\n    await Promise.all(\n      files.map((file, index) =>\n        pipeStream(\n          file,\n          // 指定位置创建可写流 加一个put避免文件夹和文件重名\n          // hash后不存在这个问题，因为文件夹没有后缀\n          // fse.createWriteStream(path.resolve(dest, '../', 'out' + filename), {\n          fse.createWriteStream(dest, {\n            start: index * size,\n            end: (index + 1) * size\n          })\n        )\n      )\n    )\n\n  }\n\n  async mergeFileChunk(filePath, fileHash, size){\n    const chunkDir = path.resolve(this.config.UPLOAD_DIR, fileHash)\n    let chunkPaths = await fse.readdir(chunkDir)\n    // 根据切片下标进行排序\n    // 否则直接读取目录的获得的顺序可能会错乱\n    chunkPaths\n      .sort((a, b) => a.split(\"-\")[1] - b.split(\"-\")[1])\n    chunkPaths = chunkPaths.map(cp => path.resolve(chunkDir, cp)) // 转成文件路径\n    await this.mergeFiles(chunkPaths, filePath, size)\n  }\n}\nmodule.exports = UploadService;"
  },
  {
    "path": "server/config/config.default.js",
    "content": "// config/config.default.js\nconst path = require('path')\nmodule.exports = appInfo => {\n  const config = {};\n  config.keys = 'shengxinjign@!#rocks!';\n  // config.middleware = ['cors'];\n  config.multipart = {\n    mode: 'file',\n    // multipart: {\n    // },\n      whitelist: ()=>true\n\n\n  }\n  config.security = {\n    csrf: {\n      enable: false\n    },\n  }\n  config.UPLOAD_DIR = path.resolve(__dirname, \"..\", \"app/public\"); // 大文件存储目录\n\n  return config;\n};"
  },
  {
    "path": "server/package.json",
    "content": "{\n  \"name\": \"server\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"start\": \"egg-bin dev\",\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"keywords\": [],\n  \"author\": \"\",\n  \"license\": \"ISC\",\n  \"dependencies\": {\n    \"egg\": \"^2.26.0\",\n    \"egg-bin\": \"^4.14.1\",\n    \"egg-cors\": \"^2.2.3\",\n    \"fs-extra\": \"^8.1.0\"\n  }\n}\n"
  }
]