upload-video.uvue 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. <template>
  2. <view class="upload-video">
  3. <view class="video-list">
  4. <view v-for="(video, index) in videoList" :key="index" class="video-item">
  5. <video class="video" :src="video.url" :poster="video.thumbnail" controls></video>
  6. <view class="video-info">
  7. <text class="video-duration">{{ formatDuration(video.duration) }}</text>
  8. <text class="video-size">{{ formatSize(video.fileSize) }}</text>
  9. </view>
  10. <view class="video-delete" @click="handleDelete(index)">
  11. <image class="delete-icon" src="/static/images/common/3.png" mode="aspectFit"></image>
  12. </view>
  13. </view>
  14. <!-- 上传按钮 -->
  15. <view v-if="videoList.length < maxCount && !uploading" class="upload-box" @click="handleChooseVideo">
  16. <text class="upload-icon">+</text>
  17. <text class="upload-text">上传视频</text>
  18. </view>
  19. <!-- 上传中 -->
  20. <view v-if="uploading" class="upload-box uploading">
  21. <text class="upload-text">上传中...</text>
  22. </view>
  23. </view>
  24. </view>
  25. </template>
  26. <script setup lang="uts">
  27. import { ref, computed } from 'vue'
  28. import { uploadVideo } from '../../api/upload/video'
  29. import type { VideoItem } from '../../types/workbench'
  30. // Props
  31. type Props = {
  32. modelValue?: VideoItem[]
  33. maxCount?: number
  34. maxSize?: number // MB
  35. maxDuration?: number // 秒
  36. businessType?: string
  37. }
  38. const props = withDefaults(defineProps<Props>(), {
  39. modelValue: () => [],
  40. maxCount: 3,
  41. maxSize: 50,
  42. maxDuration: 60,
  43. businessType: 'video'
  44. })
  45. // Emits
  46. const emit = defineEmits<{
  47. (e: 'update:modelValue', videos: VideoItem[]): void
  48. (e: 'change', videos: VideoItem[]): void
  49. }>()
  50. // 视频列表
  51. const videoList = ref<VideoItem[]>(props.modelValue)
  52. const uploading = ref<boolean>(false)
  53. // 上传视频(必须在 handleChooseVideo 之前定义)
  54. const handleUpload = async (
  55. filePath: string,
  56. duration: number,
  57. size: number,
  58. thumbnail: string
  59. ): Promise<void> => {
  60. try {
  61. // 检查文件大小
  62. if (size > props.maxSize * 1024 * 1024) {
  63. uni.showToast({
  64. title: `视频大小不能超过${props.maxSize}MB`,
  65. icon: 'none'
  66. })
  67. return
  68. }
  69. uploading.value = true
  70. // 上传视频,获取完整的文件信息
  71. const result = await uploadVideo(filePath, props.businessType)
  72. // 构建视频项,包含完整的上传响应信息和视频元数据
  73. const videoItem: VideoItem = {
  74. url: result.url,
  75. fileId: result.fileId,
  76. fileName: result.fileName,
  77. filePath: result.filePath,
  78. fileSize: result.fileSize,
  79. fileExt: result.fileExt,
  80. businessType: result.businessType,
  81. thumbnail: thumbnail,
  82. duration: duration
  83. }
  84. videoList.value.push(videoItem)
  85. emit('update:modelValue', videoList.value)
  86. emit('change', videoList.value)
  87. uni.showToast({
  88. title: '上传成功',
  89. icon: 'success'
  90. })
  91. } catch (e: any) {
  92. uni.showToast({
  93. title: e.message ?? '上传失败',
  94. icon: 'none'
  95. })
  96. } finally {
  97. uploading.value = false
  98. }
  99. }
  100. // 选择视频
  101. const handleChooseVideo = (): void => {
  102. if (videoList.value.length >= props.maxCount) {
  103. uni.showToast({
  104. title: `最多上传${props.maxCount}个视频`,
  105. icon: 'none'
  106. })
  107. return
  108. }
  109. uni.chooseVideo({
  110. sourceType: ['album', 'camera'],
  111. maxDuration: props.maxDuration,
  112. success: (res) => {
  113. // 提取属性(any 类型不能直接访问属性)
  114. const tempFilePath = res.tempFilePath as string
  115. const duration = res.duration as number
  116. const size = res.size as number
  117. // uni.chooseVideo 在 uni-app x 中可能不返回缩略图,使用视频路径作为临时缩略图
  118. const thumbnail = tempFilePath
  119. handleUpload(tempFilePath, duration, size, thumbnail)
  120. },
  121. fail: (err: any) => {
  122. console.log('选择视频失败', err)
  123. uni.showToast({
  124. title: '选择视频失败',
  125. icon: 'none'
  126. })
  127. }
  128. })
  129. }
  130. // 删除视频
  131. const handleDelete = (index: number): void => {
  132. uni.showModal({
  133. title: '确认删除',
  134. content: '确定要删除这个视频吗?',
  135. success: (res) => {
  136. const confirm = res.confirm as boolean
  137. if (confirm) {
  138. videoList.value.splice(index, 1)
  139. emit('update:modelValue', videoList.value)
  140. emit('change', videoList.value)
  141. }
  142. }
  143. })
  144. }
  145. // 格式化时长
  146. const formatDuration = (duration: number): string => {
  147. const minutes = Math.floor(duration / 60)
  148. const seconds = Math.floor(duration % 60)
  149. return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
  150. }
  151. // 格式化大小
  152. const formatSize = (size: number): string => {
  153. if (size < 1024 * 1024) {
  154. return `${(size / 1024).toFixed(2)}KB`
  155. } else {
  156. return `${(size / 1024 / 1024).toFixed(2)}MB`
  157. }
  158. }
  159. </script>
  160. <style lang="scss">
  161. .video {
  162. &-list {
  163. flex-direction: row;
  164. flex-wrap: wrap;
  165. }
  166. &-item {
  167. background-color: #f5f7fa;
  168. border-radius: 8rpx;
  169. position: relative;
  170. width: 220rpx;
  171. height: 220rpx;
  172. margin-right: 20rpx;
  173. margin-bottom: 20rpx;
  174. }
  175. width: 220rpx;
  176. height: 220rpx;
  177. border-radius: 8rpx;
  178. &-info {
  179. position: absolute;
  180. bottom: 0;
  181. left: 0;
  182. right: 0;
  183. padding: 10rpx;
  184. background-color: rgba(0, 0, 0, 0.6);
  185. border-bottom-left-radius: 8rpx;
  186. border-bottom-right-radius: 8rpx;
  187. flex-direction: row;
  188. justify-content: space-between;
  189. }
  190. &-duration,
  191. &-size {
  192. font-size: 22rpx;
  193. color: #ffffff;
  194. }
  195. &-delete {
  196. position: absolute;
  197. top: 2rpx;
  198. right: 2rpx;
  199. width: 44rpx;
  200. height: 44rpx;
  201. justify-content: center;
  202. align-items: center;
  203. }
  204. }
  205. .delete-icon {
  206. width: 32rpx;
  207. height: 32rpx;
  208. }
  209. .upload {
  210. &-box {
  211. background-color: #f5f7fa;
  212. border-radius: 8rpx;
  213. width: 220rpx;
  214. height: 220rpx;
  215. margin-right: 20rpx;
  216. margin-bottom: 20rpx;
  217. border: 2rpx dashed #d0d0d0;
  218. justify-content: center;
  219. align-items: center;
  220. }
  221. &-icon {
  222. font-size: 60rpx;
  223. color: #999999;
  224. line-height: 60rpx;
  225. margin-bottom: 10rpx;
  226. }
  227. &-text {
  228. font-size: 24rpx;
  229. color: #999999;
  230. }
  231. }
  232. .uploading {
  233. border-style: solid;
  234. border-color: #d0d0d0;
  235. background-color: #f5f7fa;
  236. }
  237. </style>