zhb 2 miesięcy temu
rodzic
commit
7743f1a266
1 zmienionych plików z 383 dodań i 248 usunięć
  1. 383 248
      components/cwg-file-picker-wrapper.vue

+ 383 - 248
components/cwg-file-picker-wrapper.vue

@@ -1,24 +1,63 @@
 <template>
   <view class="file-picker-wrapper">
-    <!-- 只读模式:仅展示文件列表 -->
+    <!-- 只读模式:仅展示 -->
     <view v-if="readonly" class="file-list readonly-list">
-      <view v-for="(file, index) in value" :key="index" class="file-item readonly-item" @click="previewFile(file)">
+      <view v-for="(file, index) in innerFileList" :key="index" class="file-item readonly-item"
+        @click="previewFile(file, index)">
         <image v-if="isImage(file)" :src="file.url || file.path" mode="aspectFill" class="file-thumb" />
-        <view v-else class="file-icon">
-          <text class="iconfont icon-file">📄</text>
+        <view v-else class="file-icon" :class="getFileClass(file.name)">
+          <text class="file-icon-text">{{ getFileIcon(file.name) }}</text>
         </view>
         <text class="file-name">{{ file.name }}</text>
       </view>
-      <view v-if="!value.length" class="empty-text">暂无文件</view>
+      <view v-if="!innerFileList.length" class="empty-text">暂无文件</view>
     </view>
 
-    <!-- 非只读模式:完整功能 -->
-    <view v-else>
-      <view class="upload-area">
-        <uni-file-picker ref="filePicker" v-bind="$attrs" :limit="999" :show-file-list="false" :auto-upload="false"
-          mode="grid" @select="handleSelect" @fail="handleFail" @error="handleError">
+    <!-- 正常模式:宫格上传(完全对齐官方 upload-image 样式) -->
+    <view v-else class="uni-file-picker__container">
+      <view class="file-picker__box" v-for="(item, index) in innerFileList" :key="index" :style="boxStyle">
+        <view class="file-picker__box-content" :style="borderStyle">
+          <!-- 图片 -->
+          <image v-if="isImage(item)" class="file-image" :src="item.url || item.path" mode="aspectFill"
+            @click.stop="previewFile(item, index)" />
+
+          <!-- 视频 → 显示第一帧 + 播放图标 -->
+          <view v-else-if="isVideo(item)" class="file-cover video-box" @click.stop="previewFile(item, index)">
+            <image :src="item.url || item.path" class="file-image" mode="aspectFill" />
+            <view class="video-play-icon">▶</view>
+          </view>
+
+          <!-- 其他文件(PDF/Word/Excel) -->
+          <view v-else class="file-cover file-box" :class="getFileClass(item.name)"
+            @click.stop="previewFile(item, index)">
+            <text class="file-big-icon">{{ getFileIcon(item.name) }}</text>
+            <text class="file-ext-name">{{ getFileExt(item.name) }}</text>
+          </view>
+
+          <!-- 删除 -->
+          <view v-if="delIcon" class="icon-del-box" @click.stop="deleteFile(index)">
+            <view class="icon-del"></view>
+            <view class="icon-del rotate"></view>
+          </view>
+
+          <!-- 进度 -->
+          <view v-if="item.status === 'uploading'" class="file-picker__progress">
+            <progress class="file-picker__progress-item" :percent="item.progress" stroke-width="4"
+              backgroundColor="#EBEBEB" />
+          </view>
+
+          <!-- 失败重试 -->
+          <view v-if="item.status === 'error'" class="file-picker__mask" @click.stop="reUploadFile(index)">
+            点击重试
+          </view>
+        </view>
+      </view>
+
+      <!-- 添加按钮 -->
+      <view v-if="innerFileList.length < limit" class="file-picker__box" :style="boxStyle">
+        <view class="file-picker__box-content is-add" :style="borderStyle" @click="handleChoose">
           <cwg-icon name="icon_add" class="upload-icon" :size="24" />
-        </uni-file-picker>
+        </view>
       </view>
     </view>
   </view>
@@ -29,54 +68,61 @@ import { computed, ref, watch } from 'vue'
 import config from '@/config'
 import { userToken } from '@/composables/config'
 
-// 定义 props
+// === Props 完全对齐官方 uni-file-picker ===
 const props = defineProps({
-  // 支持 v-model 的文件列表
   value: {
     type: Array,
     default: () => []
   },
-  // 只读模式
   readonly: {
     type: Boolean,
     default: false
   },
-  // 最大文件数量
+  disabled: {
+    type: Boolean,
+    default: false
+  },
   limit: {
     type: Number,
-    default: 5
+    default: 9
   },
-  // 文件类型限制(image/video/all)
   fileMediatype: {
     type: String,
     default: 'all'
   },
-  // 上传地址(必填)
+  delIcon: {
+    type: Boolean,
+    default: true
+  },
+  disablePreview: {
+    type: Boolean,
+    default: false
+  },
+  autoUpload: {
+    type: Boolean,
+    default: true
+  },
+  // 上传配置
   action: {
     type: String,
     default: ''
   },
-  // 上传URL(优先使用)
   uploadUrl: {
     type: String,
     default: '/custom/bank/upload'
   },
-  // 上传请求头
   uploadHeaders: {
     type: Object,
     default: () => ({})
   },
-  // 上传字段名
   uploadName: {
     type: String,
     default: 'file'
   },
-  // 上传额外数据
   uploadData: {
     type: Object,
     default: () => ({})
   },
-  // 响应数据处理函数
   responseHandler: {
     type: Function,
     default: (res) => {
@@ -85,22 +131,15 @@ const props = defineProps({
         return {
           success: data.code === 200,
           path: data.data?.path || data.data,
-          message: data.msg || '上传成功',
-          data: data
+          message: data.msg || '上传成功'
         }
       } catch (e) {
-        return {
-          success: false,
-          path: null,
-          message: '解析响应失败',
-          data: null
-        }
+        return { success: false, message: '解析失败' }
       }
     }
   }
 })
 
-// 定义 emits
 const emit = defineEmits([
   'input',
   'update:value',
@@ -108,118 +147,168 @@ const emit = defineEmits([
   'delete',
   'success',
   'fail',
-  'error',
-  'progress'
+  'progress',
+  'select'
 ])
 
-// 响应式数据
-const innerFileList = ref([])      // 内部文件列表
-const uploadProgress = ref({})     // 存储每个文件的上传进度 { fileIndex: progress }
-const isUploading = ref(false)     // 是否正在上传
+// 内部数据
+const innerFileList = ref([])
+const tempFileQueue = ref([])
 
-// 计算属性
-const remainingLimit = computed(() => {
-  return Math.max(0, props.limit - innerFileList.value.length)
-})
+// 完全对齐官方 upload-image 样式
+const boxStyle = 'width:33.3%;height:0;padding-top:33.3%;'
+const borderStyle = 'border:1px #eee solid;border-radius:5px;'
 
-// 监听外部 value 变化
+// 同步外部 value
 watch(
   () => props.value,
-  (newVal) => {
-    innerFileList.value = [...newVal]
+  (val) => {
+    innerFileList.value = val ? [...val] : []
   },
   { immediate: true, deep: true }
 )
 
-// 监听内部文件列表变化同步外部
+// 内部变化同步外部
 watch(
   innerFileList,
-  (newVal) => {
-    emit('input', newVal)
-    emit('update:value', newVal)
-    emit('change', newVal)
+  (val) => {
+    emit('input', val)
+    emit('update:value', val)
+    emit('change', val)
+    console.log(val, 1212);
+
   },
   { deep: true }
 )
 
-// 方法
-// 判断是否为图片
+// ==============================================
+// 文件类型判断
+// ==============================================
 const isImage = (file) => {
-  const type = file.type || file.fileType
-  if (type) return type.startsWith('image/')
-  const ext = (file.name || '').split('.').pop().toLowerCase()
-  return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)
+  const ext = (file.path || file.name || '').split('.').pop()?.toLowerCase()
+  return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext)
+}
+
+const isVideo = (file) => {
+  const ext = (file.path || file.name || '').split('.').pop()?.toLowerCase()
+  return ['mp4', 'mov', 'avi', 'flv', 'wmv', 'rmvb'].includes(ext)
+}
+
+const getFileExt = (name) => {
+  if (!name) return ''
+  return name.split('.').pop()?.toUpperCase()
+}
+
+// 文件图标
+const getFileIcon = (name) => {
+  const ext = getFileExt(name)
+  if (ext === 'PDF') return 'PDF'
+  if (ext === 'PDF') return 'PDF'
+  if (['DOC', 'DOCX'].includes(ext)) return 'WORD'
+  if (['XLS', 'XLSX'].includes(ext)) return 'EXCEL'
+  if (isVideo({ name })) return '▶'
+  if (isImage({ name })) return 'IMAGE'
+  // return 'FILE'
 }
 
-// 预览文件
-const previewFile = (file) => {
+// 文件样式类
+const getFileClass = (name) => {
+  const ext = getFileExt(name)
+  if (ext === 'PDF') return 'file-pdf'
+  if (['DOC', 'DOCX'].includes(ext)) return 'file-word'
+  if (['XLS', 'XLSX'].includes(ext)) return 'file-excel'
+  if (isVideo({ name })) return 'file-video'
+  return 'file-other'
+}
+
+// ==============================================
+// 预览:全部跳转到新页面使用 web-view 打开
+// ==============================================
+const previewFile = (file, index) => {
+  if (
+    props.disablePreview ||
+    file.status === 'uploading' ||
+    file.status === 'error'
+  ) {
+    return
+  }
+
+  const url = file.url || file.path
+  const name = file.name || '文件'
+
+  // 图片预览(保留原生预览,体验更好)
   if (isImage(file)) {
-    const urls = innerFileList.value
-      .filter(f => isImage(f))
-      .map(f => f.url || f.path)
-    const current = file.url || file.path
-    uni.previewImage({ urls, current })
-  } else {
-    if (file.url) {
-      // #ifdef H5
-      window.open(file.url)
-      // #endif
-      // #ifdef APP-PLUS
-      plus.runtime.openURL(file.url)
-      // #endif
-      // #ifdef MP-WEIXIN
-      uni.downloadFile({
-        url: file.url,
-        success: (res) => uni.openDocument({ filePath: res.tempFilePath })
-      })
-      // #endif
-    } else {
-      uni.showToast({ title: '暂无法预览此文件', icon: 'none' })
-    }
+    const successImages = innerFileList.value.filter(item =>
+      isImage(item) && item.status === 'success'
+    )
+    const realIndex = successImages.findIndex(
+      item => item.url === file.url && item.path === file.path
+    )
+    const urls = successImages.map(item => item.url || item.path)
+
+    uni.previewImage({
+      current: realIndex,
+      urls: urls,
+      loop: true
+    })
+    return
   }
-}
 
-// 删除文件
+  // ==============================================
+  // 视频 / PDF / Word / Excel → 跳转到 web-view 页面
+  // ==============================================
+  uni.navigateTo({
+    url: `/pages/common/webview?url=${encodeURIComponent(url)}&title=${encodeURIComponent(name)}`
+  })
+}
+// ==============================================
+// 上传 / 删除 / 重试 逻辑(已修复进度)
+// ==============================================
 const deleteFile = (index) => {
-  const deleted = innerFileList.value[index]
+  const item = innerFileList.value[index]
   innerFileList.value.splice(index, 1)
-  emit('delete', deleted, index)
-}
-
-// 处理文件选择
-const handleSelect = async (res) => {
-  const { tempFiles } = res
-  // 正常新增模式:检查数量限制
-  const availableSlots = props.limit - innerFileList.value.length
-  const toAddFiles = tempFiles.slice(0, availableSlots)
-  if (toAddFiles.length < tempFiles.length) {
-    uni.showToast({
-      title: `最多选择${props.limit}个文件,超出部分已忽略`,
-      icon: 'none'
-    })
-  }
+  emit('delete', item, index)
+}
 
-  isUploading.value = true
-  try {
-    for (let i = 0; i < toAddFiles.length; i++) {
-      const file = toAddFiles[i]
-      const fileIndex = innerFileList.value.length + i
-      await uploadFile(file, fileIndex)
+const handleChoose = () => {
+  if (props.disabled || props.readonly) return
+  const count = props.limit - innerFileList.value.length
+  uni.chooseFile({
+    type: props.fileMediatype,
+    count,
+    success: (res) => {
+      const files = res.tempFiles || res.tempFilePaths.map((p, i) => ({
+        path: p,
+        name: `file_${i}`
+      }))
+      emit('select', { tempFiles: files })
+      tempFileQueue.value = files
+      if (props.autoUpload) startUpload()
     }
-  } finally {
-    isUploading.value = false
+  })
+}
+
+const startUpload = async () => {
+  const files = tempFileQueue.value
+  tempFileQueue.value = []
+  for (const file of files) {
+    await uploadFile(file)
   }
 }
-const filePicker = ref(null)
-// 上传文件
-const uploadFile = (fileItem, fileIndex) => {
-  return new Promise((resolve, reject) => {
-    // 构建上传URL
-    const uploadUrl = props.action || (config.Host80 + props.uploadUrl)
 
-    // 构建上传参数
+const uploadFile = (fileItem) => {
+  return new Promise((resolve) => {
+    innerFileList.value.push({
+      ...fileItem,
+      status: 'uploading',
+      progress: 0
+    })
+
+    const index = innerFileList.value.length - 1
+    const url = props.action || config.Host80 + props.uploadUrl
+
     const task = uni.uploadFile({
-      url: uploadUrl,
+      url,
       filePath: fileItem.path,
       name: props.uploadName,
       header: {
@@ -227,201 +316,247 @@ const uploadFile = (fileItem, fileIndex) => {
         ...props.uploadHeaders
       },
       formData: props.uploadData,
-      success: (res) => {
 
+      success: (res) => {
         const result = props.responseHandler(res.data)
         if (result.success) {
-          // 构造文件对象
-          const uploadedFile = {
-            url: config.Host80 + result.path,
-            path: result.path,
-            name: fileItem.name,
-            size: fileItem.size,
-            type: fileItem.type,
-            response: result.data
-          }
-          innerFileList.value.push(uploadedFile)
-          emit('success', uploadedFile, res)
-          resolve(uploadedFile)
+          innerFileList.value[index].progress = 100
+          innerFileList.value[index].status = 'success'
+          innerFileList.value[index].url = config.Host80 + result.path
+          innerFileList.value[index].path = result.path
+          emit('success', innerFileList.value[index])
         } else {
-          uni.showToast({ title: result.message || '上传失败', icon: 'error' })
-          emit('fail', new Error(result.message), fileItem)
-          reject(new Error(result.message))
+          innerFileList.value[index].status = 'error'
+          uni.showToast({ title: result.message, icon: 'error' })
+          emit('fail', result.message)
         }
+        resolve(result)
       },
-      fail: (err) => {
-        // 清除进度
-        delete uploadProgress.value[fileIndex]
-
-        console.error('上传失败:', err)
-        uni.showToast({ title: '网络错误,请重试', icon: 'error' })
-        emit('fail', err, fileItem)
-        reject(err)
+
+      fail: () => {
+        innerFileList.value[index].status = 'error'
+        uni.showToast({ title: '上传失败', icon: 'error' })
+        emit('fail', '网络异常')
+        resolve(null)
       }
     })
 
-    // 监听上传进度
-    task.onProgressUpdate((progress) => {
-      uploadProgress.value[fileIndex] = progress.progress
-      emit('progress', progress, fileItem, fileIndex)
+    task.onProgressUpdate((p) => {
+      innerFileList.value[index].progress = p.progress
+      emit('progress', p, index)
     })
   })
 }
 
+const reUploadFile = (index) => {
+  const file = innerFileList.value[index]
+  uploadFile(file)
+}
+</script>
 
-
-// 处理失败事件(来自 uni-file-picker)
-const handleFail = (err) => {
-  emit('fail', err)
+<style scoped>
+/* 布局 */
+.uni-file-picker__container {
+  display: flex;
+  flex-wrap: wrap;
+  margin: -5px;
+  width: 100%;
 }
 
-// 处理错误事件(来自 uni-file-picker)
-const handleError = (err) => {
-  emit('error', err)
+.file-picker__box {
+  position: relative;
+  width: 33.3%;
+  height: 0;
+  padding-top: 33.3%;
+  box-sizing: border-box;
 }
 
-// 格式化文件大小
-const formatFileSize = (bytes) => {
-  if (!bytes) return ''
-  const sizes = ['B', 'KB', 'MB', 'GB']
-  const i = Math.floor(Math.log(bytes) / Math.log(1024))
-  return (bytes / Math.pow(1024, i)).toFixed(2) + sizes[i]
+.file-picker__box-content {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  margin: 5px;
+  border-radius: 5px;
+  overflow: hidden;
+  background: #f7f7f7;
 }
 
-// 暴露方法(可选,与原组件保持一致,未暴露任何方法)
-// defineExpose({})
-</script>
+/* 图片 */
+.file-image {
+  width: 100%;
+  height: 100%;
+}
 
-<style scoped>
-.file-picker-wrapper {
+/* 文件封面 */
+.file-cover {
   width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  color: #fff;
 }
 
-.file-list {
-  margin-bottom: 20rpx;
+/* 视频 */
+.video-box {
+  position: relative;
 }
 
-.file-item {
+.video-play-icon {
+  position: absolute;
+  font-size: 30px;
+  color: #fff;
+  background: rgba(0, 0, 0, 0.5);
+  width: 50px;
+  height: 50px;
+  border-radius: 50%;
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  background: #f8f8f8;
-  border-radius: 12rpx;
-  padding: 16rpx 20rpx;
-  margin-bottom: 16rpx;
+  justify-content: center;
+}
+
+/* 文件类型颜色 */
+.file-pdf {
+  background: #ff4f4f;
+}
+
+.file-word {
+  background: #3a7ff5;
+}
+
+.file-excel {
+  background: #24a148;
+}
+
+.file-video {
+  background: #000;
+}
+
+.file-other {
+  background: #f7f7f7;
 }
 
-.file-info {
+.file-big-icon {
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 4px;
+}
+
+.file-ext-name {
+  font-size: 12px;
+  opacity: 0.9;
+}
+
+/* 添加按钮 */
+.is-add {
   display: flex;
   align-items: center;
-  flex: 1;
-  gap: 20rpx;
+  justify-content: center;
+  background: #f7f7f7;
 }
 
-.file-thumb {
-  width: 80rpx;
-  height: 80rpx;
-  border-radius: 8rpx;
-  background: #e0e0e0;
+.upload-icon {
+  color: #999;
 }
 
-.file-icon {
-  width: 80rpx;
-  height: 80rpx;
-  background: #e0e0e0;
-  border-radius: 8rpx;
+/* 删除按钮 */
+.icon-del-box {
+  position: absolute;
+  top: 3px;
+  right: 3px;
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  background: rgba(0, 0, 0, 0.5);
   display: flex;
   align-items: center;
   justify-content: center;
+  z-index: 10;
+  transform: rotate(-45deg);
 }
 
-.file-detail {
-  flex: 1;
-  overflow: hidden;
+.icon-del {
+  width: 15px;
+  height: 2px;
+  background: #fff;
+  border-radius: 2px;
 }
 
-.file-name {
-  font-size: 28rpx;
-  color: #333;
-  display: block;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
+.rotate {
+  position: absolute;
+  transform: rotate(90deg);
 }
 
-.file-size {
-  font-size: 24rpx;
-  color: #999;
-  margin-top: 8rpx;
-  display: block;
+/* 进度 */
+.file-picker__progress {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 2;
 }
 
-.file-actions {
+/* 失败遮罩 */
+.file-picker__mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.4);
+  color: #fff;
   display: flex;
-  gap: 16rpx;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
 }
 
-.action-btn {
-  padding: 0 20rpx;
-  height: 56rpx;
-  line-height: 56rpx;
-  font-size: 24rpx;
+/* 只读模式 */
+.file-list {
+  padding: 5px;
 }
 
-.readonly-item {
-  cursor: pointer;
-  transition: opacity 0.2s;
+.file-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px;
+  background: #f8f8f8;
+  border-radius: 8px;
+  margin-bottom: 10px;
 }
 
-.readonly-item:active {
-  opacity: 0.7;
+.file-thumb,
+.file-icon {
+  width: 60px;
+  height: 60px;
+  border-radius: 6px;
 }
 
-.empty-text {
-  text-align: center;
-  color: #999;
-  font-size: 28rpx;
-  padding: 40rpx 0;
+.file-icon {
+  background: #eee;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 }
 
-.upload-area {
-  margin-top: 20rpx;
+.file-icon-text {
+  font-size: 12px;
+  font-weight: bold;
 }
 
-.upload-btn {
-  width: 100%;
-  background: #007aff;
-  color: #fff;
-  border-radius: 8rpx;
+.file-name {
+  flex: 1;
+  font-size: 14px;
+  color: #333;
 }
 
-/* 上传进度条样式 */
-.upload-progress {
-  position: relative;
-  width: 100%;
-  height: 8rpx;
-  background-color: #f0f0f0;
-  border-radius: 4rpx;
-  margin-top: 8rpx;
-  overflow: hidden;
-
-  .progress-bar {
-    position: absolute;
-    top: 0;
-    left: 0;
-    height: 100%;
-    background-color: #007aff;
-    border-radius: 4rpx;
-    transition: width 0.3s ease;
-  }
-
-  .progress-text {
-    position: absolute;
-    top: 0;
-    right: 0;
-    font-size: 20rpx;
-    color: #666;
-    line-height: 8rpx;
-  }
+.empty-text {
+  text-align: center;
+  color: #999;
+  padding: 20px 0;
 }
 </style>