Upload.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. <script>
  2. import { getCosConfig, getVideoPic } from '@/api/common'
  3. import { dateFormat, uuid } from '@/utils/index'
  4. // import Video from 'video.js'
  5. import Cos from 'cos-js-sdk-v5'
  6. import BenzAMRRecorder from 'benz-amr-recorder'
  7. import MP4Box from 'mp4box'
  8. export default {
  9. components: {},
  10. props: {
  11. // 单文件上传时使用
  12. fileUrl: {
  13. type: String,
  14. default: '',
  15. },
  16. fileName: {
  17. type: String,
  18. default: '',
  19. },
  20. // 多文件上传时使用,例如: [{name: 'food.jpg', url: 'https://xxx.cdn.com/xxx.jpg'}]
  21. fileList: {
  22. type: Array,
  23. default: () => [],
  24. },
  25. // 0 图片(image)、1 语音(voice)、2 视频(video),3 普通文件(file)
  26. type: {
  27. type: String,
  28. default: '0',
  29. validator: function (value) {
  30. // 这个值必须匹配下列字符串中的一个
  31. return ['0', '1', '2', '3'].includes(value)
  32. },
  33. },
  34. // 上传文件大小不能超过 maxSize MB, 各类型有默认限制 参见: maxSizeDefault
  35. maxSize: {
  36. type: Number,
  37. default: undefined,
  38. },
  39. // 图片的宽高像素限制 [width(number), height(number)],默认null不限制
  40. maxImgPx: {
  41. type: Array,
  42. default: () => [1440, 1080], // () => [100, 100]
  43. },
  44. // 允许上传的文件格式后缀名 eg:["jpg", "png"],['*']为不限制,各类型有默认限制 参见: formatDefault
  45. format: {
  46. type: Array,
  47. default: undefined,
  48. },
  49. // 选择上传文件类型, 用于过滤系统选择文件类型, 默认根据类型自动匹配:见 acceptAuto,
  50. accept: {
  51. type: String,
  52. default: '',
  53. },
  54. // 是否能上传多个
  55. multiple: {
  56. type: Boolean,
  57. default: false,
  58. },
  59. // multiple为true时有效,最多上传几个,默认不限制,最小为1
  60. limit: {
  61. type: Number,
  62. default: undefined,
  63. },
  64. // onProgress: {
  65. // type: Function,
  66. // default: null,
  67. // },
  68. // beforeUpload: {
  69. // type: Function,
  70. // default: function() {
  71. // return function() {}
  72. // },
  73. // },
  74. // 图片操作 目前支持有 view:查看,remove:删除,download:下载
  75. // action: {
  76. // type: Array,
  77. // default: () => ["view", "remove"],
  78. // },
  79. },
  80. data() {
  81. return {
  82. loading: false,
  83. fileUrlWatch: this.fileUrl,
  84. fileNameWatch: this.fileName,
  85. fileListWatch: this.fileList,
  86. picUrl: '', // 视频第一帧
  87. file: undefined,
  88. speed: 0, // 上传网速
  89. percentage: 0, //上传进度
  90. // cos配置信息
  91. cosConfig: { bucketName: '', cosImgUrlPrefix: '', region: '' },
  92. cosInstance: undefined, // cos实例
  93. }
  94. },
  95. watch: {
  96. fileUrl: {
  97. handler(value) {
  98. this.fileUrlWatch = value
  99. },
  100. },
  101. fileName: {
  102. handler(value) {
  103. this.fileNameWatch = value
  104. },
  105. },
  106. fileList: {
  107. handler(value) {
  108. this.fileListWatch = value
  109. },
  110. },
  111. loading(val) {
  112. this.$emit('loadingChange', val)
  113. },
  114. },
  115. computed: {
  116. // 识别选择文件类型
  117. acceptAuto() {
  118. return ['image/*', 'amr/*', 'video/*'][this.type]
  119. },
  120. },
  121. created() {
  122. getCosConfig().then((res) => {
  123. this.cosConfig = res
  124. this.cosInstance = new Cos({
  125. SecretId: res.secretId,
  126. SecretKey: res.secretKey,
  127. })
  128. })
  129. },
  130. mounted() {
  131. // const cosInstance = new Cos({
  132. // // getAuthorization 必选参数
  133. // getAuthorization: function (options, callback) {
  134. // // 服务端例子:https://github.com/tencentyun/qcloud-cos-sts-sdk/blob/master/scope.md
  135. // // 异步获取临时密钥
  136. // var url = window.lwConfig.SYSTEM_API + '/file/get/config' // url替换成您自己的后端服务
  137. // var xhr = new XMLHttpRequest()
  138. // xhr.open('get', url, true)
  139. // xhr.setRequestHeader('Content-Type', 'application/json')
  140. // xhr.onload = function (e) {
  141. // try {
  142. // var data = JSON.parse(e.target.responseText)
  143. // var credentials = data.credentials
  144. // } catch (e) {}
  145. // if (!data || !credentials) {
  146. // return console.error('credentials invalid:\n' + JSON.stringify(data, null, 2))
  147. // }
  148. // callback({
  149. // TmpSecretId: decrypt(credentials.secretId),
  150. // TmpSecretKey: credentials.secretKey,
  151. // SecurityToken: credentials.sessionToken,
  152. // // 建议返回服务器时间作为签名的开始时间,避免用户浏览器本地时间偏差过大导致签名错误
  153. // StartTime: data.startTime, // 时间戳,单位秒,如:1580000000
  154. // ExpiredTime: data.expiredTime, // 时间戳,单位秒,如:1580000000
  155. // // ScopeLimit: true, // 细粒度控制权限需要设为 true,会限制密钥只在相同请求时重复使用
  156. // })
  157. // }
  158. // xhr.send()
  159. // // xhr.send(JSON.stringify(options.Scope))
  160. // },
  161. // })
  162. },
  163. methods: {
  164. upload() {
  165. this.loading = true
  166. let file = undefined
  167. if (!this.multiple || this.limit == 1) {
  168. file = this.file
  169. } else {
  170. // 多选上传是多次调用单传的
  171. file = this.file.shift()
  172. }
  173. let date = new Date()
  174. let format = file.name.match(/\.(\w+)$/g)
  175. format = format && format[0]
  176. const params = {
  177. Bucket: this.cosConfig.bucketName /* 填入您自己的存储桶,必须字段 */,
  178. Region: this.cosConfig.region /* 存储桶所在地域,例如ap-beijing,必须字段 */,
  179. Key: `/${dateFormat(date, 'yyyy-MM-dd')}/t${date.getTime()}-${uuid()}${format}`,
  180. /* 存储在桶里的对象键(例如1.jpg,a/b/test.txt),必须字段。*/
  181. // 此处使用的格式: /日期/t时间戳-uid-文件后缀名
  182. }
  183. // 实例可能未初始化完成
  184. if (!this.cosInstance) {
  185. this.$message.error('存储空间正忙,请稍后再试')
  186. return
  187. }
  188. this.cosInstance.uploadFile(
  189. {
  190. ...params,
  191. Body: file /* 必须,上传文件对象,可以是input[type="file"]的file对象 */,
  192. onProgress: (progressData) => {
  193. this.percentage = progressData.percent * 100
  194. this.speed = (progressData.speed / 1024 / 1024).toFixed(2)
  195. // this.onProgress && this.onProgress(this.percentage, this.speed)
  196. },
  197. },
  198. (err1, data) => {
  199. this.percentage = this.speed = 0
  200. if (err1) {
  201. this.loading = false
  202. this.$message.error('上传失败,请稍后再试')
  203. } else {
  204. let location = 'https://' + data.Location
  205. this.type == 2
  206. ? //获取视频第一帧画面
  207. getVideoPic({ url: location }).then((res) => {
  208. this.loading = false
  209. this.$emit('getPicUrl', res.data.url)
  210. })
  211. : (this.loading = false)
  212. // 使用本地链接提供预览,避免上传后下载的问题
  213. let url = window.URL.createObjectURL(file)
  214. let name = file.name
  215. if (!this.multiple) {
  216. this.fileUrlWatch = url
  217. this.$emit('update:fileUrl', location)
  218. this.$emit('update:fileName', (this.fileNameWatch = name))
  219. } else {
  220. this.fileListWatch = this.fileListWatch.concat({ name, url })
  221. this.$emit('update:fileList', this.fileList.concat({ name, url: location }))
  222. }
  223. }
  224. },
  225. )
  226. },
  227. remove(i) {
  228. this.$confirm('确定要删除吗?', '提示', {
  229. confirmButtonText: '确定',
  230. cancelButtonText: '取消',
  231. type: 'warning',
  232. }).then(() => {
  233. let clone = JSON.parse(JSON.stringify(this.fileList))
  234. this.fileListWatch.splice(i, 1)
  235. clone.splice(i, 1)
  236. this.$emit('update:fileList', clone)
  237. })
  238. },
  239. handleExceed(file, fileList) {
  240. this.$message.error('最多上传' + this.limit + '张')
  241. this.loading = false
  242. },
  243. async handleBeforeUpload(file, filelist) {
  244. this.loading = true
  245. let isFormat = true
  246. let isSize = true
  247. // type: 0 图片(image)、1 语音(voice)、2 视频(video),3 普通文件(file)
  248. // 统一校验文件后缀名格式
  249. // 如果没有显示配置 format 格式限制,则使用默认下面校验
  250. let format = this.format
  251. let tip = ''
  252. if (!format || !format.length) {
  253. let formatDefault = {
  254. 0: { tip: 'png/jpg', value: ['png', 'jpg', 'jpeg'] },
  255. 1: { value: ['amr'] },
  256. 2: { value: ['mp4'] },
  257. 3: { tip: 'word/pdf/ppt', value: ['doc', 'docx', 'pdf', 'ppt', 'pptx', 'pps', 'pptsx'] },
  258. }
  259. format = formatDefault[this.type].value
  260. tip = formatDefault[this.type].tip
  261. }
  262. let match = file.name.match(/\.(\w+)$/g)
  263. let fileFormat = match && match[0].replace('.', '').toLowerCase()
  264. isFormat = format[0] === '*' || format.includes(fileFormat)
  265. if (!isFormat) {
  266. this.$message.error('文件格式错误,仅支持 ' + (tip || format.join(',')) + ' 格式!')
  267. this.loading = false
  268. return Promise.reject()
  269. }
  270. // 统一校验文件体积
  271. // 如果没有显式配置 maxSize 限制大小,则使用下面默认值,单位 MB,
  272. let maxSize = this.maxSize
  273. if (!maxSize) {
  274. let maxSizeDefault = { 0: 2, 1: 2, 2: 100, 3: 50 }
  275. maxSize || (maxSize = maxSizeDefault[this.type])
  276. }
  277. isSize = file.size / 1024 / 1024 < maxSize
  278. if (!isSize) {
  279. this.$message.error('上传文件大小不能超过 ' + maxSize + 'MB!')
  280. this.loading = false
  281. return Promise.reject()
  282. }
  283. // 各类型独有的校验
  284. let validate = true
  285. if (this.type === '0') {
  286. // 图片
  287. let maxImgPx = this.maxImgPx
  288. if (maxImgPx) {
  289. try {
  290. await new Promise((resolve) => {
  291. let width, height
  292. let image = new Image()
  293. //加载图片获取图片真实宽度和高度
  294. image.onload = () => {
  295. width = image.width
  296. height = image.height
  297. if (width > maxImgPx[0]) {
  298. validate = false
  299. this.$message.error(`图片“宽”度超过${maxImgPx[0]}像素,请重新选择`)
  300. } else if (height > maxImgPx[1]) {
  301. this.$message.error(`图片“高”度超过${maxImgPx[1]}像素,请重新选择`)
  302. validate = false
  303. }
  304. window.URL && window.URL.revokeObjectURL(image.src)
  305. resolve()
  306. }
  307. if (window.URL) {
  308. let url = window.URL.createObjectURL(file)
  309. image.src = url
  310. } else if (window.FileReader) {
  311. let reader = new FileReader()
  312. reader.onload = function (e) {
  313. let data = e.target.result
  314. image.src = data
  315. }
  316. reader.readAsDataURL(file)
  317. }
  318. })
  319. } catch (e) {
  320. console.error(e)
  321. }
  322. }
  323. } else if (this.type === '1') {
  324. // 语音
  325. let amr = new BenzAMRRecorder()
  326. try {
  327. await amr.initWithBlob(file)
  328. validate = amr.getDuration() <= 60
  329. if (!validate) {
  330. this.$message.error('上传文件时长不能超过 60秒!')
  331. }
  332. } catch (error) {
  333. console.log(error)
  334. this.$message.error('文件已损坏')
  335. }
  336. } else if (this.type === '2') {
  337. // 视频
  338. let result = await this.checkVideoCode(file)
  339. if (result.mime.indexOf('video/mp4') === -1) {
  340. this.$message.error('mp4 格式不正确, 请使用标准编码视频!')
  341. this.loading = false
  342. return Promise.reject()
  343. }
  344. } else if (this.type === '3') {
  345. // 普通文件
  346. }
  347. if (!validate) {
  348. this.loading = false
  349. }
  350. // if (beforeUpload) {
  351. // return beforeUpload(file)
  352. // }
  353. if (!this.multiple || this.limit == 1) {
  354. this.file = file
  355. } else {
  356. Array.isArray(this.file) || (this.file = []) // 多选
  357. this.file.push(file)
  358. }
  359. return validate || Promise.reject()
  360. },
  361. onError(err, file, fileList) {
  362. this.loading = false
  363. this.$message.error('上传文件失败')
  364. },
  365. checkVideoCode(file) {
  366. return new Promise((resolve, reject) => {
  367. const mp4boxFile = MP4Box.createFile()
  368. const reader = new FileReader()
  369. reader.readAsArrayBuffer(file)
  370. reader.onload = function (e) {
  371. const arrayBuffer = e.target.result
  372. arrayBuffer.fileStart = 0
  373. mp4boxFile.appendBuffer(arrayBuffer)
  374. }
  375. mp4boxFile.onReady = function (info) {
  376. resolve(info)
  377. }
  378. mp4boxFile.onError = function (info) {
  379. reject(info)
  380. }
  381. })
  382. },
  383. showView(index) {
  384. let imager = index !== undefined ? this.$refs.image[index] : this.$refs.image
  385. imager.clickHandler()
  386. this.$nextTick(() => {
  387. // 为遮罩层添加关闭事件
  388. let maskEl = imager.$children[0].$refs['el-image-viewer__wrapper'].firstChild
  389. maskEl.addEventListener('click', () => {
  390. event.stopPropagation()
  391. imager.closeViewer()
  392. })
  393. })
  394. // this.$refs.image.$refs.closeViewer();
  395. },
  396. },
  397. }
  398. </script>
  399. <template>
  400. <div>
  401. <!-- 多个上传文件列表展示 -->
  402. <template v-if="multiple">
  403. <!-- 图片 -->
  404. <template v-if="type == 0">
  405. <!-- <transition-group> -->
  406. <div v-for="(item, index) in fileListWatch" :key="index" class="img-item upload-item">
  407. <el-image
  408. ref="image"
  409. class="upload-img uploader-size"
  410. :src="item.url"
  411. fit="contain"
  412. :preview-src-list="fileListWatch.map((e) => e.url)"
  413. alt="" />
  414. <div class="action-mask">
  415. <i class="el-icon-search mr5" @click="showView(index)"></i>
  416. <!-- <span v-if="action.includes('download')" @click="download(item)">
  417. <i class="el-icon-download"></i>
  418. </span> -->
  419. <i class="el-icon-delete mr5" @click="remove(index)"></i>
  420. </div>
  421. </div>
  422. <!-- </transition-group> -->
  423. </template>
  424. <!-- 后续再这里扩展其他文件列表 -->
  425. </template>
  426. <el-upload
  427. v-if="!multiple || !limit || fileListWatch.length < limit"
  428. class="uploader"
  429. action="/api"
  430. :accept="accept || acceptAuto"
  431. :http-request="upload"
  432. :data="{ mediaType: type }"
  433. :show-file-list="false"
  434. :file-list="fileListWatch"
  435. :disabled="loading"
  436. :multiple="multiple && limit != 1"
  437. :limit="limit"
  438. :on-error="onError"
  439. :on-exceed="handleExceed"
  440. :before-upload="handleBeforeUpload">
  441. <!--
  442. element-loading-text="正在上传..."
  443. :on-success="onSuccess"
  444. -->
  445. <slot>
  446. <i v-if="!loading && !fileUrlWatch" class="el-icon-plus uploader-icon upload-action uploader-size"></i>
  447. <transition>
  448. <!-- 上传进度条 -->
  449. <div class="upload-action uploader-size" v-if="loading">
  450. <el-progress class="progress cc" type="circle" :percentage="percentage"></el-progress>
  451. <div class="el-loading-spinner">
  452. <svg viewBox="25 25 50 50" class="circular">
  453. <circle cx="50" cy="50" r="20" fill="none" class="path"></circle>
  454. </svg>
  455. </div>
  456. <div class="cc" style="margin-top: 35px">
  457. {{ speed + 'M/s' }}
  458. </div>
  459. </div>
  460. <!-- 单文件上传的文件展示 -->
  461. <div v-if="!loading && fileUrlWatch && !multiple" class="upload-item">
  462. <template v-if="type === '0'">
  463. <el-image
  464. v-if="fileUrlWatch"
  465. ref="image"
  466. :src="fileUrlWatch"
  467. class="upload-img upload-img-single uploader-size"
  468. :preview-src-list="[fileUrlWatch]"
  469. fit="contain" />
  470. <div class="action-mask" @click.self.stop>
  471. <i class="el-icon-search" @click.prevent.stop="showView()"></i>
  472. <i class="el-icon-edit"></i>
  473. <!-- <span v-if="action.includes('download')" @click.prevent.stop="download(item)">
  474. <i class="el-icon-download"></i>
  475. </span> -->
  476. <!-- <span @click.prevent.stop="remove()">
  477. <i class="el-icon-delete"></i>
  478. </span> -->
  479. </div>
  480. </template>
  481. <template v-else-if="type === '2'">
  482. <video
  483. ref="video"
  484. id="myVideo"
  485. class="upload-video"
  486. width="100%"
  487. controls
  488. webkit-playsinline="true"
  489. playsinline="true"
  490. :autoplay="false"
  491. :key="fileUrlWatch"
  492. preload="auto">
  493. <source :src="fileUrlWatch" type="video/mp4" />
  494. </video>
  495. <div class="action-mask" style="height: 30%" @click.self.stop>
  496. <i class="el-icon-edit"></i>
  497. </div>
  498. </template>
  499. <template v-else class="al">
  500. {{ fileNameWatch || fileUrlWatch }}
  501. <i class="el-icon-edit ml10"></i>
  502. <!-- a链接用本地视频打不开,视频地址使用远程地址 -->
  503. <a @click.stop :href="/\.mp4$/.test(fileNameWatch) ? fileUrl : fileUrlWatch" target="_blank">
  504. <i class="el-icon-view ml10" style="vertical-align: middle"></i>
  505. </a>
  506. </template>
  507. </div>
  508. </transition>
  509. </slot>
  510. </el-upload>
  511. <!-- 上传格式,大小等提示语 -->
  512. <div class="tip">
  513. <slot name="tip"></slot>
  514. </div>
  515. </div>
  516. </template>
  517. <style lang="scss" scoped>
  518. ::v-deep.uploader {
  519. display: inline-block;
  520. vertical-align: middle;
  521. .el-upload {
  522. display: block;
  523. border-radius: 6px;
  524. cursor: pointer;
  525. position: relative;
  526. overflow: hidden;
  527. }
  528. }
  529. // 默认组件大小,如需修改,请通过外部重写该类样式
  530. .uploader-size {
  531. width: 178px;
  532. height: 178px;
  533. }
  534. .upload-action {
  535. position: relative;
  536. text-align: center;
  537. }
  538. .uploader-icon {
  539. display: flex;
  540. font-size: 28px;
  541. align-items: center;
  542. justify-content: center;
  543. color: #8c939d;
  544. border-radius: 6px;
  545. border: 1px dashed #d9d9d9;
  546. transition: all 0.3s;
  547. &:hover {
  548. border-color: var(--color);
  549. color: var(--color);
  550. }
  551. }
  552. .progress {
  553. overflow: hidden;
  554. }
  555. .upload-img-single {
  556. border: 1px dashed #eee;
  557. }
  558. .upload-img {
  559. display: block;
  560. }
  561. .upload-video {
  562. display: block;
  563. width: 300px;
  564. height: 150px;
  565. box-sizing: border-box;
  566. color: #fff;
  567. background-color: #000;
  568. position: relative;
  569. padding: 0;
  570. font-size: 10px;
  571. line-height: 1;
  572. font-weight: normal;
  573. font-style: normal;
  574. word-break: initial;
  575. }
  576. .tip {
  577. margin-top: 10px;
  578. color: #aaa;
  579. font-size: 12px;
  580. }
  581. .img-item {
  582. position: relative;
  583. display: inline-block;
  584. vertical-align: middle;
  585. margin: 0 10px 10px 0;
  586. transition: all 0.3s;
  587. }
  588. .action-mask {
  589. position: absolute;
  590. display: flex;
  591. justify-content: space-around;
  592. align-items: center;
  593. padding: 0 30px;
  594. width: 100%;
  595. height: 100%;
  596. left: 0;
  597. top: 0;
  598. cursor: pointer;
  599. text-align: center;
  600. color: #fff;
  601. opacity: 0;
  602. font-size: 20px;
  603. transition: opacity 0.3s;
  604. background-color: rgba(0, 0, 0, 0.5);
  605. z-index: 1;
  606. }
  607. .upload-item {
  608. &:hover {
  609. .action-mask {
  610. opacity: 1;
  611. }
  612. }
  613. }
  614. </style>