فهرست منبع

图片视频混合组件

xiaocao 7 ماه پیش
والد
کامیت
b3dc45ceaf
1فایلهای تغییر یافته به همراه875 افزوده شده و 0 حذف شده
  1. 875 0
      src/components/imageWithVideo/imageWithVideo.vue

+ 875 - 0
src/components/imageWithVideo/imageWithVideo.vue

@@ -0,0 +1,875 @@
+<template>
+  <view class="media-uploader">
+    <view class="media-grid">
+      <view class="media-item" v-for="(item, index) in mediaList" :key="index">
+        <!-- 图片预览 -->
+        <image v-if="item.type === 'image'" :src="item.url" mode="aspectFill" @tap="previewMedia(index)"></image>
+        <!-- 视频预览 --> 
+        <video v-else-if="item.type === 'video'" :id="'video-' + index" :src="item.url" class="video-preview" controls
+          show-center-play-btn enable-play-gesture show-fullscreen-btn object-fit="cover" :initial-time="0"
+          :show-mute-btn="true" :enable-progress-gesture="true" :vslide-gesture="true"
+          :vslide-gesture-in-fullscreen="true" @tap="previewVideo(index)" :poster="item.thumbnail"></video>
+        <!-- 删除按钮 -->
+        <view class="delete-btn" @tap.stop="deleteMedia(index)">
+          <uni-icons type="close" size="12" color="#fff"></uni-icons>
+        </view>
+
+      </view>
+
+      <!-- 上传按钮 -->
+      <view class="upload-trigger" v-if="mediaList.length < maxCount">
+        <view class="upload-box" @tap="chooseMedia">
+          <uni-icons type="upload" size="48" color="#999"></uni-icons>
+          <text class="upload-text">{{ mediaList.length }}/{{ maxCount }}</text>
+        </view>
+      </view>
+    </view>
+    <text class="upload-tip">最多上传{{ maxCount }}个文件</text>
+  </view>
+</template>
+
+<script>
+	
+export default {
+  name: 'MediaUploader', // 组件名称
+  props: {
+    // 最大上传数量限制
+    maxCount: {
+      type: Number,
+      default: 9 // 默认最多9个文件
+    },
+    // 已有的媒体列表,支持v-model双向绑定
+    value: {
+      type: Array,
+      default: () => [] // 默认为空数组
+    },
+    // 文件上传的服务器地址(可选)
+    uploadUrl: {
+      type: String,
+      required: false // 不传则使用本地文件路径
+    },
+    // 认证token,用于上传时的身份验证(可选)
+    token: {
+      type: String,
+      required: false // 不传则不上传认证头
+    },
+    // 媒体类型限制,控制用户只能选择什么类型的文件
+    mediaType: {
+      type: String,
+      default: 'all',  // 默认支持所有类型
+      validator: function (value) {
+        // 验证传入的值是否合法
+        return ['all', 'image', 'video'].includes(value)
+      }
+    },
+    // 是否启用媒体模式,true时显示"拍摄/相册"选择,false时直接选择文件
+    media: {
+      type: Boolean,
+      default: true // 默认启用媒体模式
+    }
+  },
+  data() {
+    return {
+      mediaList: [] // 内部维护的媒体文件列表
+    }
+  },
+  watch: {
+    // 监听外部传入的value值变化
+    value: {
+      handler(newVal) {
+        // 确保传入的是数组格式
+        if (!Array.isArray(newVal)) {
+          console.warn('MediaUploader: value must be an array');
+          return;
+        }
+
+        // 将外部数据转换为内部格式
+        this.mediaList = newVal.map(item => {
+          // 如果已经是正确的对象格式(包含url和type),直接使用
+          if (typeof item === 'object' && item.url && item.type) {
+            return {
+              url: item.url,
+              type: item.type,
+              backendData: item.backendData || null // 保留后端数据
+            };
+          }
+
+          // 如果是字符串或其他格式,转换为标准对象格式
+          const url = typeof item === 'object' ? item.url : item;
+          return {
+            url: url, // 文件路径
+            type: this.getMediaType(item), // 自动判断文件类型
+            backendData: null // 没有后端数据
+          }
+        })
+      },
+      immediate: true // 组件创建时立即执行一次
+    }
+  },
+  methods: {
+    /**
+     * 选择媒体文件的主入口方法
+     * 根据不同的配置和平台,调用相应的选择方法
+     */
+    async chooseMedia() {
+      try {
+        // 检查是否已达到最大上传数量限制
+        if (this.mediaList.length >= this.maxCount) {
+          uni.showToast({
+            title: `最多只能上传${this.maxCount}个文件`,
+            icon: 'none'
+          });
+          return;
+        }
+
+        // 获取当前运行平台信息,用于平台差异化处理
+        const platform = uni.getSystemInfoSync().platform;
+        const isH5 = platform === 'h5';
+
+        // 如果启用了媒体模式,显示"拍摄/相册"选择菜单
+        if (this.media) {
+          // 使用Promise包装uni.showActionSheet,确保跨平台兼容性
+          const res = await new Promise((resolve, reject) => {
+            uni.showActionSheet({
+              itemList: ['拍摄', '从相册选择'], // 显示两个选项
+              success: resolve,
+              fail: reject
+            });
+          });
+
+          if (res.tapIndex === 0) {
+            // 用户选择了"拍摄"选项
+            if (this.mediaType === 'video') {
+              this.captureVideo(); // 调用视频拍摄方法
+            } else {
+              this.captureImage(); // 调用图片拍摄方法
+            }
+          } else {
+            // 用户选择了"从相册选择"选项
+            this.chooseFromAlbum(); // 调用相册选择方法
+          }
+          return; // 媒体模式下处理完毕,直接返回
+        }
+
+        // 非媒体模式下的处理逻辑
+        if (this.mediaType === 'image') {
+          // 如果只允许选择图片,直接调用图片选择方法
+          this.chooseImage();
+        } else if (this.mediaType === 'video') {
+          // 如果只允许选择视频,根据平台调用不同的视频选择方法
+          if (isH5) {
+            this.chooseVideoH5(); // H5平台使用专门的视频选择方法
+          } else {
+            this.chooseVideoMini(); // 小程序和APP使用通用视频选择方法
+          }
+        } else {
+          // 如果允许选择所有类型('all'),显示类型选择菜单
+          const res = await new Promise((resolve, reject) => {
+            uni.showActionSheet({
+              itemList: ['选择图片', '选择视频'], // 让用户选择文件类型
+              success: resolve,
+              fail: reject
+            });
+          });
+
+          if (res.tapIndex === 0) {
+            // 用户选择了"选择图片"
+            this.chooseImage();
+          } else {
+            // 用户选择了"选择视频"
+            if (isH5) {
+              this.chooseVideoH5();
+            } else {
+              this.chooseVideoMini();
+            }
+          }
+        }
+      } catch (e) {
+        // 捕获并记录选择过程中的错误
+        console.error('选择媒体文件失败:', e);
+      }
+    },
+
+    /**
+     * 选择图片方法
+     * 调用系统图片选择器,支持从相册选择或拍照
+     */
+    async chooseImage() {
+      try {
+        // 使用Promise包装uni.chooseImage,确保跨平台兼容性
+        const res = await new Promise((resolve, reject) => {
+          uni.chooseImage({
+            count: this.maxCount - this.mediaList.length, // 可选择的图片数量
+            sizeType: ['original', 'compressed'], // 支持原图和压缩图
+            sourceType: ['album', 'camera'], // 支持从相册选择和拍照
+            success: resolve,
+            fail: reject
+          });
+        });
+
+        // 遍历选择的图片文件,逐个处理
+        for (let tempFilePath of res.tempFilePaths) {
+          try {
+            // 调用上传方法处理文件(可能是上传或本地路径)
+            const uploadResult = await this.uploadFile(tempFilePath);
+            // 创建新的媒体对象
+            const newMedia = {
+              url: uploadResult.data.link, // 使用前端文件路径进行预览
+              type: 'image', // 标记为图片类型
+              backendData: uploadResult.data.backendData // 保存后端返回的数据
+            };
+            // 更新媒体列表
+            this.updateMediaList([...this.mediaList, newMedia]);
+          } catch (uploadError) {
+            // 处理单个图片上传失败的情况
+            console.error('图片上传失败:', uploadError);
+            uni.showToast({
+              title: '图片上传失败',
+              icon: 'none'
+            });
+          }
+        }
+      } catch (e) {
+        // 处理图片选择失败的情况
+        console.error('选择图片失败:', e);
+        uni.showToast({
+          title: '选择图片失败',
+          icon: 'none'
+        });
+      }
+    },
+
+    /**
+     * H5平台专用视频选择方法
+     * H5平台对视频处理有特殊要求,需要单独处理
+     */
+    async chooseVideoH5() {
+      try {
+        // 使用Promise包装uni.chooseVideo,确保跨平台兼容性
+        const res = await new Promise((resolve, reject) => {
+          uni.chooseVideo({
+            sourceType: ['album', 'camera'], // 支持从相册选择和拍摄
+            compressed: true, // 启用压缩
+            camera: 'back', // 默认使用后置摄像头
+            success: resolve,
+            fail: reject
+          });
+        });
+
+        // 显示加载提示
+        uni.showLoading({
+          title: '处理中...'
+        });
+
+        try {
+          // 处理视频文件(上传或本地路径)
+          const videoResult = await this.uploadFile(res.tempFilePath);
+
+          // 创建视频媒体对象
+          const newMedia = {
+            url: videoResult.data.link, // 使用前端文件路径进行预览
+            type: 'video', // 标记为视频类型
+            duration: Math.round(res.duration), // 视频时长(秒)
+            // H5平台暂时不支持视频预览图,可以后续在服务端处理
+            thumbnail: '',
+            backendData: videoResult.data.backendData // 保存后端返回的数据
+          };
+
+          // 更新媒体列表
+          this.updateMediaList([...this.mediaList, newMedia]);
+          uni.hideLoading(); // 隐藏加载提示
+        } catch (error) {
+          // 处理视频处理失败的情况
+          uni.hideLoading();
+          uni.showToast({
+            title: '视频处理失败',
+            icon: 'none'
+          });
+          console.error('视频处理失败:', error);
+        }
+      } catch (e) {
+        // 处理视频选择失败的情况
+        console.error('选择视频失败:', e);
+        uni.showToast({
+          title: '选择视频失败',
+          icon: 'none'
+        });
+      }
+    },
+
+    /**
+     * 非H5平台视频选择方法(小程序和APP)
+     * 不同平台使用不同的API,需要条件编译处理
+     */
+    async chooseVideoMini() {
+      try {
+        // #ifdef MP-WEIXIN
+        // 微信小程序使用 chooseMedia API
+        wx.chooseMedia({
+          count: 1, // 一次只能选择一个视频
+          mediaType: ['video'], // 只选择视频类型
+          success: async (res) => {
+            const tempFile = res.tempFiles[0]; // 获取选择的视频文件
+            try {
+              // 显示加载提示
+              uni.showLoading({
+                title: '处理中...'
+              });
+
+              // 处理视频文件(上传或本地路径)
+              const videoResult = await this.uploadFile(tempFile.tempFilePath);
+
+              // 处理视频预览图(微信小程序会自动生成)
+              const thumbResult = await this.uploadFile(tempFile.thumbTempFilePath);
+
+              // 创建视频媒体对象
+              const newMedia = {
+                url: videoResult.data.link, // 使用前端文件路径进行预览
+                type: 'video', // 标记为视频类型
+                duration: Math.round(tempFile.duration), // 视频时长
+                thumbnail: thumbResult.data.backendData ? thumbResult.data.backendData.link : thumbResult.data.link, // 预览图路径
+                backendData: videoResult.data.backendData // 保存后端返回的数据
+              };
+
+              // 更新媒体列表
+              this.updateMediaList([...this.mediaList, newMedia]);
+              uni.hideLoading(); // 隐藏加载提示
+            } catch (error) {
+              // 处理视频处理失败的情况
+              uni.hideLoading();
+              uni.showToast({
+                title: '视频处理失败',
+                icon: 'none'
+              });
+              console.error('视频处理失败:', error);
+            }
+          },
+          fail: (err) => {
+            // 处理视频选择失败的情况
+            console.error('选择视频失败:', err);
+            uni.showToast({
+              title: '选择视频失败',
+              icon: 'none'
+            });
+          }
+        });
+        // #endif
+
+        // #ifdef APP-PLUS
+        // APP端使用 uni.chooseVideo API
+        uni.chooseVideo({
+          sourceType: ['camera', 'album'], // 支持拍摄和从相册选择
+          compressed: true, // 启用压缩
+          success: async (res) => {
+            try {
+              // 显示加载提示
+              uni.showLoading({
+                title: '处理中...'
+              });
+
+              // 处理视频文件(上传或本地路径)
+              const videoResult = await this.uploadFile(res.tempFilePath);
+
+              // 获取视频缩略图(APP端需要手动获取)
+              const thumbPath = await this.getVideoThumb(res.tempFilePath);
+              let thumbnail = '';
+
+              if (thumbPath) {
+                try {
+                  // 处理缩略图文件
+                  const thumbResult = await this.uploadFile(thumbPath);
+                  thumbnail = thumbResult.data.link; // 使用前端文件路径
+                } catch (error) {
+                  console.error('缩略图上传失败:', error);
+                }
+              }
+
+              // 创建视频媒体对象
+              const newMedia = {
+                url: videoResult.data.link, // 使用前端文件路径进行预览
+                type: 'video', // 标记为视频类型
+                duration: Math.round(res.duration || 0), // 视频时长
+                thumbnail: thumbnail || videoResult.data.link, // 如果没有缩略图,使用视频地址作为预览图
+                backendData: videoResult.data.backendData // 保存后端返回的数据
+              };
+
+              // 更新媒体列表
+              this.updateMediaList([...this.mediaList, newMedia]);
+              uni.hideLoading(); // 隐藏加载提示
+            } catch (error) {
+              // 处理视频处理失败的情况
+              uni.hideLoading();
+              uni.showToast({
+                title: '视频处理失败',
+                icon: 'none'
+              });
+              console.error('视频处理失败:', error);
+            }
+          },
+          fail: (err) => {
+            // 处理视频选择失败的情况
+            console.error('选择视频失败:', err);
+            uni.showToast({
+              title: '选择视频失败',
+              icon: 'none'
+            });
+          }
+        });
+        // #endif
+
+        // #ifdef H5
+        // 如果在H5环境下不小心调用了这个方法,就调用H5的方法
+        this.chooseVideoH5();
+        // #endif
+      } catch (e) {
+        console.error('选择视频失败:', e);
+        uni.showToast({
+          title: '选择视频失败',
+          icon: 'none'
+        });
+      }
+    },
+
+    /**
+     * 拍摄图片方法
+     * 调用相机拍摄新图片
+     */
+    async captureImage() {
+      try {
+        // 使用Promise包装uni.chooseImage,确保跨平台兼容性
+        const res = await new Promise((resolve, reject) => {
+          uni.chooseImage({
+            count: 1, // 一次只能拍摄一张图片
+            sizeType: ['original', 'compressed'], // 支持原图和压缩图
+            sourceType: ['camera'], // 只使用相机拍摄
+            success: resolve,
+            fail: reject
+          });
+        });
+
+        // 处理拍摄的图片文件
+        for (let tempFilePath of res.tempFilePaths) {
+          try {
+            // 处理图片文件(上传或本地路径)
+            const uploadResult = await this.uploadFile(tempFilePath);
+            // 创建图片媒体对象
+            const newMedia = {
+              url: uploadResult.data.link, // 使用前端文件路径进行预览
+              type: 'image', // 标记为图片类型
+              backendData: uploadResult.data.backendData // 保存后端返回的数据
+            };
+            // 更新媒体列表
+            this.updateMediaList([...this.mediaList, newMedia]);
+          } catch (uploadError) {
+            // 处理图片处理失败的情况
+            console.error('图片上传失败:', uploadError);
+            uni.showToast({
+              title: '图片上传失败',
+              icon: 'none'
+            });
+          }
+        }
+      } catch (e) {
+        // 处理拍摄失败的情况
+        console.error('拍摄图片失败:', e);
+      }
+    },
+
+    /**
+     * 从相册选择文件方法(支持图片和视频)
+     * 不同平台使用不同的API,需要条件编译处理
+     */
+    async chooseFromAlbum() {
+      // #ifdef MP-WEIXIN
+      // 微信小程序使用 chooseMedia API
+      try {
+        const res = await new Promise((resolve, reject) => {
+          wx.chooseMedia({
+            count: this.maxCount - this.mediaList.length, // 可选择的文件数量
+            mediaType: ['image', 'video'], // 支持图片和视频
+            sourceType: ['album'], // 只从相册选择
+            success: resolve,
+            fail: reject
+          });
+        });
+
+        // 遍历选择的文件,逐个处理
+        for (let tempFile of res.tempFiles) {
+          try {
+            // 显示加载提示
+            uni.showLoading({ title: '处理中...' });
+
+            // 处理文件(上传或本地路径)
+            const fileResult = await this.uploadFile(tempFile.tempFilePath);
+
+            if (tempFile.fileType === 'video') {
+              // 处理视频文件
+              const thumbPath = tempFile.thumbTempFilePath; // 微信小程序自动生成的预览图
+              let thumbnail = '';
+              if (thumbPath) {
+                try {
+                  // 处理预览图文件
+                  const thumbResult = await this.uploadFile(thumbPath);
+                  thumbnail = thumbResult.data.link; // 使用前端文件路径
+                } catch (error) {
+                  console.error('缩略图上传失败:', error);
+                }
+              }
+
+              // 创建视频媒体对象
+              const newMedia = {
+                url: fileResult.data.link, // 使用前端文件路径进行预览
+                type: 'video', // 标记为视频类型
+                duration: Math.round(tempFile.duration || 0), // 视频时长
+                thumbnail: thumbnail || fileResult.data.link, // 预览图路径
+                backendData: fileResult.data.backendData // 保存后端返回的数据
+              };
+              this.updateMediaList([...this.mediaList, newMedia]);
+            } else {
+              // 处理图片文件
+              const newMedia = {
+                url: fileResult.data.link, // 使用前端文件路径进行预览
+                type: 'image', // 标记为图片类型
+                backendData: fileResult.data.backendData // 保存后端返回的数据
+              };
+              this.updateMediaList([...this.mediaList, newMedia]);
+            }
+
+            uni.hideLoading(); // 隐藏加载提示
+          } catch (error) {
+            // 处理文件处理失败的情况
+            uni.hideLoading();
+            uni.showToast({
+              title: '上传失败',
+              icon: 'none'
+            });
+            console.error('上传失败:', error);
+          }
+        }
+      } catch (e) {
+        console.error('选择失败:', e);
+        uni.showToast({
+          title: '选择失败',
+          icon: 'none'
+        });
+      }
+      // #endif
+
+      // #ifdef APP-PLUS || H5
+      // APP和H5使用系统相册
+      try {
+        const res = await uni.chooseImage({
+          count: this.maxCount - this.mediaList.length,
+          sizeType: ['original', 'compressed'],
+          sourceType: ['album']
+        });
+
+        // 上传图片
+        for (let tempFilePath of res.tempFilePaths) {
+          try {
+            const uploadResult = await this.uploadFile(tempFilePath);
+            const newMedia = {
+              url: uploadResult.data.link, // 使用前端文件路径进行预览
+              type: 'image',
+              backendData: uploadResult.data.backendData // 保存后端返回的数据
+            };
+            this.updateMediaList([...this.mediaList, newMedia]);
+          } catch (uploadError) {
+            console.error('上传失败:', uploadError);
+            uni.showToast({
+              title: '上传失败',
+              icon: 'none'
+            });
+          }
+        }
+      } catch (e) {
+        console.error('选择图片失败:', e);
+        uni.showToast({
+          title: '选择图片失败',
+          icon: 'none'
+        });
+      }
+      // #endif
+    },
+
+    /**
+     * 获取视频缩略图方法
+     * 不同平台使用不同的API获取视频预览图
+     * @param {string} videoPath 视频文件路径
+     * @returns {Promise<string>} 缩略图路径
+     */
+    async getVideoThumb(videoPath) {
+      return new Promise((resolve, reject) => {
+        try {
+          // #ifdef APP-PLUS
+          // APP端优先使用 plus.io.getVideoInfo API
+          plus.io.getVideoInfo({
+            filePath: videoPath,
+            success: (info) => {
+              if (info.thumbnail) {
+                resolve(info.thumbnail); // 返回获取到的缩略图路径
+              } else {
+                // 如果获取不到缩略图,尝试使用 uni.getVideoInfo
+                this.getVideoThumbUnified(videoPath).then(resolve).catch(() => resolve(''));
+              }
+            },
+            error: () => {
+              // 如果 plus.io.getVideoInfo 失败,尝试使用 uni.getVideoInfo
+              this.getVideoThumbUnified(videoPath).then(resolve).catch(() => resolve(''));
+            }
+          });
+          // #endif
+
+          // #ifndef APP-PLUS
+          // 非APP平台直接使用 uni.getVideoInfo
+          this.getVideoThumbUnified(videoPath).then(resolve).catch(() => resolve(''));
+          // #endif
+        } catch (e) {
+          // 处理获取缩略图失败的情况
+          console.error('获取视频缩略图失败:', e);
+          resolve(''); // 失败时返回空字符串
+        }
+      });
+    },
+
+    /**
+     * 统一的视频缩略图获取方法
+     * 使用 uni.getVideoInfo API 获取视频信息
+     * @param {string} videoPath 视频文件路径
+     * @returns {Promise<string>} 缩略图路径
+     */
+    async getVideoThumbUnified(videoPath) {
+      return new Promise((resolve, reject) => {
+        uni.getVideoInfo({
+          src: videoPath, // 视频文件路径
+          success: (res) => {
+            if (res.thumbTempFilePath) {
+              resolve(res.thumbTempFilePath); // 返回缩略图路径
+            } else {
+              reject(new Error('No thumbnail')); // 没有缩略图时抛出错误
+            }
+          },
+          fail: reject // 获取失败时抛出错误
+        });
+      });
+    },
+
+    /**
+     * 文件上传方法
+     * 支持上传到服务器或使用本地文件路径
+     * @param {string} filePath 文件路径
+     * @returns {Promise<Object>} 上传结果,包含文件链接和后端数据
+     */
+    async uploadFile(filePath) {
+      // 如果没有配置上传URL,直接返回本地文件路径(不上传)
+      if (!this.uploadUrl) {
+        return {
+          data: {
+            link: filePath, // 直接使用本地文件路径
+            backendData: null // 没有后端数据
+          }
+        };
+      }
+
+      // 有上传URL时,执行文件上传
+      return new Promise((resolve, reject) => {
+        // 构建请求头
+        const header = {};
+        if (this.token) {
+          header["Authorization"] = "Bearer " + this.token; // 添加认证头
+        }
+
+        // 执行文件上传
+        uni.uploadFile({
+          url: this.uploadUrl, // 上传地址
+          filePath: filePath, // 文件路径
+          header: header, // 请求头
+          name: 'file', // 文件字段名
+          success: (res) => {
+            const responseData = JSON.parse(res.data);
+            resolve({
+              data: {
+                link: filePath, // 使用前端文件路径进行预览
+                backendData: responseData // 保存后端返回的完整数据
+              }
+            });
+          },
+          fail: reject // 上传失败时抛出错误
+        });
+      });
+    },
+
+    /**
+     * 删除媒体文件方法
+     * @param {number} index 要删除的文件索引
+     */
+    deleteMedia(index) {
+      const newList = [...this.mediaList]; // 创建新数组
+      newList.splice(index, 1); // 删除指定索引的文件
+      this.updateMediaList(newList); // 更新媒体列表
+    },
+
+    /**
+     * 预览媒体文件方法
+     * 目前只支持图片预览,视频预览由视频组件自己处理
+     * @param {number} index 要预览的文件索引
+     */
+    previewMedia(index) {
+      const item = this.mediaList[index]; // 获取要预览的文件
+      if (item.type === 'image') {
+        // 图片预览:获取所有图片的URL列表
+        const imageUrls = this.mediaList
+          .filter(item => item.type === 'image') // 过滤出所有图片
+          .map(item => item.url); // 提取图片URL
+        const currentIndex = imageUrls.indexOf(item.url); // 计算当前图片在列表中的索引
+
+        // 调用系统图片预览
+        uni.previewImage({
+          urls: imageUrls, // 所有图片的URL列表
+          current: currentIndex // 当前显示的图片索引
+        });
+      }
+    },
+
+    /**
+     * 预览视频方法
+     * @param {number} index 要预览的视频索引
+     */
+    previewVideo(index) {
+      const item = this.mediaList[index];
+      if (item.type === 'video') {
+        // H5环境下不需要特殊处理,让视频组件自己处理全屏
+        // #ifdef MP-WEIXIN
+        const videoContext = uni.createVideoContext(`video-${index}`, this);
+        if (videoContext) {
+          videoContext.requestFullScreen();
+        }
+        // #endif
+      }
+    },
+
+    /**
+     * 更新媒体列表方法
+     * 更新内部数据并触发事件通知父组件
+     * @param {Array} list 新的媒体列表
+     */
+    updateMediaList(list) {
+      this.mediaList = list; // 更新内部数据
+      this.$emit('input', list.map(item => item.url)); // 触发v-model更新,只传递URL数组
+      this.$emit('change', list); // 触发change事件,传递完整的媒体对象数组(包含后端数据)
+      this.$emit('backendData', list.map(item => item.backendData).filter(data => data !== null)); // 触发后端数据事件
+    },
+
+    /**
+     * 获取媒体类型方法
+     * 根据文件URL或对象判断是图片还是视频
+     * @param {string|Object} url 文件URL或媒体对象
+     * @returns {string} 媒体类型:'image' 或 'video'
+     */
+    getMediaType(url) {
+      // 如果已经是对象格式且包含type属性,直接返回其类型
+      if (typeof url === 'object' && url.type) {
+        return url.type;
+      }
+
+      // 如果是字符串,则通过文件扩展名判断类型
+      if (typeof url === 'string') {
+        const videoExts = ['.mp4', '.mov', '.avi', '.wmv']; // 支持的视频格式
+        const ext = url.substring(url.lastIndexOf('.')).toLowerCase(); // 获取文件扩展名
+        return videoExts.includes(ext) ? 'video' : 'image'; // 根据扩展名判断类型
+      }
+
+      // 默认返回图片类型
+      return 'image';
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.media-uploader {
+  padding: 15px;
+
+  .media-grid {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+  }
+
+  .media-item {
+    position: relative;
+    width: calc((100% - 20px) / 3);
+    height: 0;
+    padding-bottom: calc((100% - 20px) / 3);
+    background-color: #f8f8f8;
+    border-radius: 8px;
+    overflow: hidden;
+
+    image,
+    video {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    .delete-btn {
+      position: absolute;
+      top: 5px;
+      right: 5px;
+      width: 20px;
+      height: 20px;
+      background: rgba(0, 0, 0, 0.5);
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      z-index: 2;
+    }
+
+    .media-mask {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      height: 30px;
+      background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      z-index: 1;
+    }
+  }
+
+  .upload-trigger {
+    width: calc((100% - 20px) / 3);
+    height: 0;
+    padding-bottom: calc((100% - 20px) / 3);
+    position: relative;
+
+    .upload-box {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      background-color: #f8f8f8;
+      border-radius: 8px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+
+      .upload-text {
+        margin-top: 5px;
+        font-size: 12px;
+        color: #999;
+      }
+    }
+  }
+
+  .upload-tip {
+    margin-top: 10px;
+    font-size: 12px;
+    color: #999;
+  }
+}
+</style>