MediaUploader.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. <template>
  2. <view class="media-uploader">
  3. <view class="media-grid">
  4. <view class="media-item" v-for="(item, index) in mediaList" :key="index">
  5. <!-- 图片预览 -->
  6. <image v-if="item.type === 'image'" :src="item.url" mode="aspectFill" @tap="previewMedia(index)">
  7. </image>
  8. <!-- 视频预览 -->
  9. <video v-else-if="item.type === 'video'" :id="'video-' + index" :src="item.url" class="video-preview"
  10. controls show-center-play-btn enable-play-gesture show-fullscreen-btn object-fit="cover"
  11. :initial-time="0" :show-mute-btn="true" :enable-progress-gesture="true" :vslide-gesture="true"
  12. :vslide-gesture-in-fullscreen="true" @tap="previewVideo(index)" :poster="item.thumbnail"></video>
  13. <!-- 删除按钮 -->
  14. <view class="delete-btn" @tap.stop="deleteMedia(index)">
  15. <uni-icons type="close" size="12" color="#fff"></uni-icons>
  16. </view>
  17. </view>
  18. <!-- 上传按钮 -->
  19. <view class="upload-trigger" v-if="mediaList.length < maxCount">
  20. <view class="upload-box" @tap="chooseMedia">
  21. <uni-icons type="camera" size="24" color="#999"></uni-icons>
  22. <text class="upload-text">{{mediaList.length}}/{{maxCount}}</text>
  23. </view>
  24. </view>
  25. </view>
  26. <text class="upload-tip">最多上传{{maxCount}}个文件</text>
  27. </view>
  28. </template>
  29. <script>
  30. export default {
  31. name: 'MediaUploader',
  32. props: {
  33. // 最大上传数量
  34. maxCount: {
  35. type: Number,
  36. default: 9
  37. },
  38. // 已有的媒体列表
  39. value: {
  40. type: Array,
  41. default: () => []
  42. },
  43. // 上传地址
  44. uploadUrl: {
  45. type: String,
  46. required: true
  47. },
  48. // 认证token
  49. token: {
  50. type: String,
  51. required: true
  52. },
  53. // 媒体类型限制
  54. mediaType: {
  55. type: String,
  56. default: 'all', // 可选值:'all'、'image'、'video'
  57. validator: function(value) {
  58. return ['all', 'image', 'video'].includes(value)
  59. }
  60. },
  61. // 是否使用媒体模式(拍照/相册)
  62. media: {
  63. type: Boolean,
  64. default: false
  65. }
  66. },
  67. data() {
  68. return {
  69. mediaList: []
  70. }
  71. },
  72. watch: {
  73. value: {
  74. handler(newVal) {
  75. if (!Array.isArray(newVal)) {
  76. console.warn('MediaUploader: value must be an array');
  77. return;
  78. }
  79. this.mediaList = newVal.map(item => {
  80. // 如果已经是正确的格式,直接返回
  81. if (typeof item === 'object' && item.url && item.type) {
  82. return item;
  83. }
  84. // 如果是字符串,转换为对象格式
  85. const url = typeof item === 'object' ? item.url : item;
  86. return {
  87. url: url,
  88. type: this.getMediaType(item)
  89. }
  90. })
  91. },
  92. immediate: true
  93. }
  94. },
  95. methods: {
  96. // 选择媒体文件
  97. async chooseMedia() {
  98. try {
  99. // 检查是否达到最大数量
  100. if (this.mediaList.length >= this.maxCount) {
  101. uni.showToast({
  102. title: `最多只能上传${this.maxCount}个文件`,
  103. icon: 'none'
  104. });
  105. return;
  106. }
  107. // 获取平台信息
  108. const platform = uni.getSystemInfoSync().platform;
  109. const isH5 = platform === 'h5';
  110. // 如果是媒体模式
  111. if (this.media) {
  112. try {
  113. const res = await uni.showActionSheet({
  114. itemList: ['拍摄', '从相册选择']
  115. });
  116. if (res.tapIndex === 0) {
  117. // 拍摄模式
  118. if (this.mediaType === 'video') {
  119. this.captureVideo();
  120. } else {
  121. this.captureImage();
  122. }
  123. } else {
  124. // 从相册选择,支持图片和视频
  125. this.chooseFromAlbum();
  126. }
  127. return;
  128. } catch (error) {
  129. console.error('选择操作失败:', error);
  130. return;
  131. }
  132. }
  133. // 非媒体模式的原有逻辑
  134. if (this.mediaType === 'image') {
  135. this.chooseImage();
  136. } else if (this.mediaType === 'video') {
  137. if (isH5) {
  138. this.chooseVideoH5();
  139. } else {
  140. this.chooseVideoMini();
  141. }
  142. } else {
  143. // 如果是 'all',则显示选择菜单
  144. try {
  145. const res = await uni.showActionSheet({
  146. itemList: ['选择图片', '选择视频']
  147. });
  148. if (res.tapIndex === 0) {
  149. this.chooseImage();
  150. } else {
  151. if (isH5) {
  152. this.chooseVideoH5();
  153. } else {
  154. this.chooseVideoMini();
  155. }
  156. }
  157. } catch (error) {
  158. console.error('选择操作失败:', error);
  159. return;
  160. }
  161. }
  162. } catch (e) {
  163. console.error(e);
  164. }
  165. },
  166. // 选择图片
  167. async chooseImage() {
  168. try {
  169. const res = await uni.chooseImage({
  170. count: this.maxCount - this.mediaList.length,
  171. sizeType: ['original', 'compressed'],
  172. sourceType: ['album', 'camera']
  173. });
  174. // 上传图片
  175. for (let tempFilePath of res.tempFilePaths) {
  176. try {
  177. const uploadResult = await this.uploadFile(tempFilePath);
  178. const newMedia = {
  179. url: uploadResult.data.link,
  180. type: 'image'
  181. };
  182. this.updateMediaList([...this.mediaList, newMedia]);
  183. } catch (uploadError) {
  184. console.error('图片上传失败:', uploadError);
  185. uni.showToast({
  186. title: '图片上传失败',
  187. icon: 'none'
  188. });
  189. }
  190. }
  191. } catch (e) {
  192. console.error(e);
  193. }
  194. },
  195. // H5平台选择视频
  196. async chooseVideoH5() {
  197. try {
  198. const res = await uni.chooseVideo({
  199. sourceType: ['album', 'camera'],
  200. compressed: true,
  201. camera: 'back'
  202. });
  203. uni.showLoading({
  204. title: '处理中...'
  205. });
  206. try {
  207. // 上传视频文件
  208. const videoResult = await this.uploadFile(res.tempFilePath);
  209. const newMedia = {
  210. url: videoResult.data.link,
  211. type: 'video',
  212. duration: Math.round(res.duration),
  213. // H5暂时不支持视频预览图,可以后续在服务端处理
  214. thumbnail: ''
  215. };
  216. this.updateMediaList([...this.mediaList, newMedia]);
  217. uni.hideLoading();
  218. } catch (error) {
  219. uni.hideLoading();
  220. uni.showToast({
  221. title: '视频处理失败',
  222. icon: 'none'
  223. });
  224. console.error('视频处理失败:', error);
  225. }
  226. } catch (e) {
  227. console.error('选择视频失败:', e);
  228. uni.showToast({
  229. title: '选择视频失败',
  230. icon: 'none'
  231. });
  232. }
  233. },
  234. // 非H5平台选择视频(小程序和APP)
  235. async chooseVideoMini() {
  236. try {
  237. // #ifdef MP-WEIXIN
  238. // 微信小程序使用 chooseMedia
  239. wx.chooseMedia({
  240. count: 1,
  241. mediaType: ['video'],
  242. success: async (res) => {
  243. const tempFile = res.tempFiles[0];
  244. try {
  245. uni.showLoading({
  246. title: '处理中...'
  247. });
  248. // 上传视频文件
  249. const videoResult = await this.uploadFile(tempFile.tempFilePath);
  250. // 上传视频预览图
  251. const thumbResult = await this.uploadFile(tempFile.thumbTempFilePath);
  252. const newMedia = {
  253. url: videoResult.data.link,
  254. type: 'video',
  255. duration: Math.round(tempFile.duration),
  256. thumbnail: thumbResult.data.link
  257. };
  258. this.updateMediaList([...this.mediaList, newMedia]);
  259. uni.hideLoading();
  260. } catch (error) {
  261. uni.hideLoading();
  262. uni.showToast({
  263. title: '视频处理失败',
  264. icon: 'none'
  265. });
  266. console.error('视频处理失败:', error);
  267. }
  268. },
  269. fail: (err) => {
  270. console.error('选择视频失败:', err);
  271. uni.showToast({
  272. title: '选择视频失败',
  273. icon: 'none'
  274. });
  275. }
  276. });
  277. // #endif
  278. // #ifdef APP-PLUS
  279. // APP端使用 chooseVideo
  280. uni.chooseVideo({
  281. sourceType: ['camera', 'album'],
  282. compressed: true,
  283. success: async (res) => {
  284. try {
  285. uni.showLoading({
  286. title: '处理中...'
  287. });
  288. // 上传视频文件
  289. const videoResult = await this.uploadFile(res.tempFilePath);
  290. // 获取视频缩略图
  291. const thumbPath = await this.getVideoThumb(res.tempFilePath);
  292. let thumbnail = '';
  293. if (thumbPath) {
  294. try {
  295. const thumbResult = await this.uploadFile(thumbPath);
  296. thumbnail = thumbResult.data.link;
  297. } catch (error) {
  298. console.error('缩略图上传失败:', error);
  299. }
  300. }
  301. const newMedia = {
  302. url: videoResult.data.link,
  303. type: 'video',
  304. duration: Math.round(res.duration || 0),
  305. thumbnail: thumbnail || videoResult.data
  306. .link // 如果没有缩略图,使用视频地址作为预览图
  307. };
  308. this.updateMediaList([...this.mediaList, newMedia]);
  309. uni.hideLoading();
  310. } catch (error) {
  311. uni.hideLoading();
  312. uni.showToast({
  313. title: '视频处理失败',
  314. icon: 'none'
  315. });
  316. console.error('视频处理失败:', error);
  317. }
  318. },
  319. fail: (err) => {
  320. console.error('选择视频失败:', err);
  321. uni.showToast({
  322. title: '选择视频失败',
  323. icon: 'none'
  324. });
  325. }
  326. });
  327. // #endif
  328. // #ifdef H5
  329. // 如果在H5环境下不小心调用了这个方法,就调用H5的方法
  330. this.chooseVideoH5();
  331. // #endif
  332. } catch (e) {
  333. console.error('选择视频失败:', e);
  334. uni.showToast({
  335. title: '选择视频失败',
  336. icon: 'none'
  337. });
  338. }
  339. },
  340. // 拍摄视频
  341. async captureVideo() {
  342. try {
  343. const res = await uni.chooseVideo({
  344. sourceType: ['camera'],
  345. compressed: true,
  346. camera: 'back'
  347. });
  348. uni.showLoading({
  349. title: '处理中...'
  350. });
  351. try {
  352. // 上传视频文件
  353. const videoResult = await this.uploadFile(res.tempFilePath);
  354. const newMedia = {
  355. url: videoResult.data.link,
  356. type: 'video',
  357. duration: Math.round(res.duration),
  358. thumbnail: ''
  359. };
  360. this.updateMediaList([...this.mediaList, newMedia]);
  361. uni.hideLoading();
  362. } catch (error) {
  363. uni.hideLoading();
  364. uni.showToast({
  365. title: '视频处理失败',
  366. icon: 'none'
  367. });
  368. console.error('视频处理失败:', error);
  369. }
  370. } catch (e) {
  371. console.error('拍摄视频失败:', e);
  372. uni.showToast({
  373. title: '拍摄视频失败',
  374. icon: 'none'
  375. });
  376. }
  377. },
  378. // 拍摄图片
  379. async captureImage() {
  380. try {
  381. const res = await uni.chooseImage({
  382. count: 1,
  383. sizeType: ['original', 'compressed'],
  384. sourceType: ['camera']
  385. });
  386. // 上传图片
  387. for (let tempFilePath of res.tempFilePaths) {
  388. try {
  389. const uploadResult = await this.uploadFile(tempFilePath);
  390. const newMedia = {
  391. url: uploadResult.data.link,
  392. type: 'image'
  393. };
  394. this.updateMediaList([...this.mediaList, newMedia]);
  395. } catch (uploadError) {
  396. console.error('图片上传失败:', uploadError);
  397. uni.showToast({
  398. title: '图片上传失败',
  399. icon: 'none'
  400. });
  401. }
  402. }
  403. } catch (e) {
  404. console.error(e);
  405. }
  406. },
  407. // 从相册选择(支持图片和视频)
  408. async chooseFromAlbum() {
  409. // #ifdef MP-WEIXIN
  410. try {
  411. const res = await new Promise((resolve, reject) => {
  412. wx.chooseMedia({
  413. count: this.maxCount - this.mediaList.length,
  414. mediaType: ['image', 'video'],
  415. sourceType: ['album'],
  416. success: resolve,
  417. fail: reject
  418. });
  419. });
  420. for (let tempFile of res.tempFiles) {
  421. try {
  422. uni.showLoading({
  423. title: '处理中...'
  424. });
  425. // 上传文件
  426. const fileResult = await this.uploadFile(tempFile.tempFilePath);
  427. if (tempFile.fileType === 'video') {
  428. // 处理视频
  429. const thumbPath = tempFile.thumbTempFilePath;
  430. let thumbnail = '';
  431. if (thumbPath) {
  432. try {
  433. const thumbResult = await this.uploadFile(thumbPath);
  434. thumbnail = thumbResult.data.link;
  435. } catch (error) {
  436. console.error('缩略图上传失败:', error);
  437. }
  438. }
  439. const newMedia = {
  440. url: fileResult.data.link,
  441. type: 'video',
  442. duration: Math.round(tempFile.duration || 0),
  443. thumbnail: thumbnail || fileResult.data.link
  444. };
  445. this.updateMediaList([...this.mediaList, newMedia]);
  446. } else {
  447. // 处理图片
  448. const newMedia = {
  449. url: fileResult.data.link,
  450. type: 'image'
  451. };
  452. this.updateMediaList([...this.mediaList, newMedia]);
  453. }
  454. uni.hideLoading();
  455. } catch (error) {
  456. uni.hideLoading();
  457. uni.showToast({
  458. title: '上传失败',
  459. icon: 'none'
  460. });
  461. console.error('上传失败:', error);
  462. }
  463. }
  464. } catch (e) {
  465. console.error('选择失败:', e);
  466. uni.showToast({
  467. title: '选择失败',
  468. icon: 'none'
  469. });
  470. }
  471. // #endif
  472. // #ifdef APP-PLUS || H5
  473. // APP和H5使用系统相册
  474. try {
  475. const res = await uni.chooseImage({
  476. count: this.maxCount - this.mediaList.length,
  477. sizeType: ['original', 'compressed'],
  478. sourceType: ['album']
  479. });
  480. // 上传图片
  481. for (let tempFilePath of res.tempFilePaths) {
  482. try {
  483. const uploadResult = await this.uploadFile(tempFilePath);
  484. const newMedia = {
  485. url: uploadResult.data.link,
  486. type: 'image'
  487. };
  488. this.updateMediaList([...this.mediaList, newMedia]);
  489. } catch (uploadError) {
  490. console.error('上传失败:', uploadError);
  491. uni.showToast({
  492. title: '上传失败',
  493. icon: 'none'
  494. });
  495. }
  496. }
  497. } catch (e) {
  498. console.error(e);
  499. }
  500. // #endif
  501. },
  502. async getVideoThumb(videoPath) {
  503. return new Promise((resolve, reject) => {
  504. try {
  505. // #ifdef APP-PLUS
  506. // 先尝试使用 plus.io.getVideoInfo
  507. plus.io.getVideoInfo({
  508. filePath: videoPath,
  509. success: (info) => {
  510. if (info.thumbnail) {
  511. resolve(info.thumbnail);
  512. } else {
  513. // 如果获取不到缩略图,尝试使用 uni.getVideoInfo
  514. this.getVideoThumbUnified(videoPath).then(resolve).catch(() =>
  515. resolve(''));
  516. }
  517. },
  518. error: () => {
  519. // 如果 plus.io.getVideoInfo 失败,尝试使用 uni.getVideoInfo
  520. this.getVideoThumbUnified(videoPath).then(resolve).catch(() =>
  521. resolve(''));
  522. }
  523. });
  524. // #endif
  525. // #ifndef APP-PLUS
  526. // 非APP平台使用 uni.getVideoInfo
  527. this.getVideoThumbUnified(videoPath).then(resolve).catch(() => resolve(''));
  528. // #endif
  529. } catch (e) {
  530. console.error('获取视频缩略图失败:', e);
  531. resolve('');
  532. }
  533. });
  534. },
  535. // 使用 uni.getVideoInfo 获取视频缩略图
  536. async getVideoThumbUnified(videoPath) {
  537. return new Promise((resolve, reject) => {
  538. uni.getVideoInfo({
  539. src: videoPath,
  540. success: (res) => {
  541. if (res.thumbTempFilePath) {
  542. resolve(res.thumbTempFilePath);
  543. } else {
  544. reject(new Error('No thumbnail'));
  545. }
  546. },
  547. fail: reject
  548. });
  549. });
  550. },
  551. // 文件上传方法
  552. async uploadFile(filePath) {
  553. uni.showLoading({
  554. title: '上传中...',
  555. mask: true
  556. })
  557. return new Promise((resolve, reject) => {
  558. uni.uploadFile({
  559. url: this.uploadUrl,
  560. filePath: filePath,
  561. header: {
  562. "Blade-Auth": this.token
  563. },
  564. name: 'file',
  565. success: (res) => {
  566. resolve(JSON.parse(res.data));
  567. uni.hideLoading()
  568. },
  569. fail: reject
  570. });
  571. });
  572. },
  573. // 删除媒体
  574. deleteMedia(index) {
  575. const newList = [...this.mediaList];
  576. newList.splice(index, 1);
  577. this.updateMediaList(newList);
  578. },
  579. // 预览媒体
  580. previewMedia(index) {
  581. const item = this.mediaList[index];
  582. if (item.type === 'image') {
  583. const imageUrls = this.mediaList
  584. .filter(item => item.type === 'image')
  585. .map(item => item.url);
  586. const currentIndex = imageUrls.indexOf(item.url);
  587. uni.previewImage({
  588. urls: imageUrls,
  589. current: currentIndex
  590. });
  591. }
  592. },
  593. // 预览视频
  594. previewVideo(index) {
  595. const item = this.mediaList[index];
  596. if (item.type === 'video') {
  597. // H5环境下不需要特殊处理,让视频组件自己处理全屏
  598. // #ifdef MP-WEIXIN
  599. const videoContext = uni.createVideoContext(`video-${index}`, this);
  600. if (videoContext) {
  601. videoContext.requestFullScreen();
  602. }
  603. // #endif
  604. }
  605. },
  606. // 更新媒体列表
  607. updateMediaList(list) {
  608. this.mediaList = list;
  609. this.$emit('input', list.map(item => item.url));
  610. this.$emit('change', list);
  611. },
  612. // 获取媒体类型
  613. getMediaType(url) {
  614. // 如果已经是对象格式,直接返回其类型
  615. if (typeof url === 'object' && url.type) {
  616. return url.type;
  617. }
  618. // 如果是字符串,则通过扩展名判断
  619. if (typeof url === 'string') {
  620. const videoExts = ['.mp4', '.mov', '.avi', '.wmv'];
  621. const ext = url.substring(url.lastIndexOf('.')).toLowerCase();
  622. return videoExts.includes(ext) ? 'video' : 'image';
  623. }
  624. // 默认返回图片类型
  625. return 'image';
  626. }
  627. }
  628. }
  629. </script>
  630. <style lang="scss" scoped>
  631. .media-uploader {
  632. padding: 15px;
  633. .media-grid {
  634. display: flex;
  635. flex-wrap: wrap;
  636. gap: 10px;
  637. }
  638. .media-item {
  639. position: relative;
  640. width: calc((100% - 20px) / 3);
  641. height: 0;
  642. padding-bottom: calc((100% - 20px) / 3);
  643. background-color: #f8f8f8;
  644. border-radius: 8px;
  645. overflow: hidden;
  646. image,
  647. video {
  648. position: absolute;
  649. width: 100%;
  650. height: 100%;
  651. object-fit: cover;
  652. }
  653. .delete-btn {
  654. position: absolute;
  655. top: 5px;
  656. right: 5px;
  657. width: 20px;
  658. height: 20px;
  659. background: rgba(0, 0, 0, 0.5);
  660. border-radius: 50%;
  661. display: flex;
  662. align-items: center;
  663. justify-content: center;
  664. z-index: 2;
  665. }
  666. .media-mask {
  667. position: absolute;
  668. bottom: 0;
  669. left: 0;
  670. right: 0;
  671. height: 30px;
  672. background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
  673. display: flex;
  674. align-items: center;
  675. justify-content: center;
  676. z-index: 1;
  677. }
  678. }
  679. .upload-trigger {
  680. width: calc((100% - 20px) / 3);
  681. height: 0;
  682. padding-bottom: calc((100% - 20px) / 3);
  683. position: relative;
  684. .upload-box {
  685. position: absolute;
  686. width: 100%;
  687. height: 100%;
  688. background-color: #f8f8f8;
  689. border-radius: 8px;
  690. display: flex;
  691. flex-direction: column;
  692. align-items: center;
  693. justify-content: center;
  694. .upload-text {
  695. margin-top: 5px;
  696. font-size: 12px;
  697. color: #999;
  698. }
  699. }
  700. }
  701. .upload-tip {
  702. margin-top: 10px;
  703. font-size: 12px;
  704. color: #999;
  705. }
  706. }
  707. </style>