zhb 2 ay önce
ebeveyn
işleme
a4280a7e65
1 değiştirilmiş dosya ile 427 ekleme ve 0 silme
  1. 427 0
      components/cwg-file-picker-wrapper.vue

+ 427 - 0
components/cwg-file-picker-wrapper.vue

@@ -0,0 +1,427 @@
+<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)">
+        <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>
+        <text class="file-name">{{ file.name }}</text>
+      </view>
+      <view v-if="!value.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">
+          <cwg-icon name="icon_add" class="upload-icon" :size="24" />
+        </uni-file-picker>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref, watch } from 'vue'
+import config from '@/config'
+import { userToken } from '@/composables/config'
+
+// 定义 props
+const props = defineProps({
+  // 支持 v-model 的文件列表
+  value: {
+    type: Array,
+    default: () => []
+  },
+  // 只读模式
+  readonly: {
+    type: Boolean,
+    default: false
+  },
+  // 最大文件数量
+  limit: {
+    type: Number,
+    default: 5
+  },
+  // 文件类型限制(image/video/all)
+  fileMediatype: {
+    type: String,
+    default: 'all'
+  },
+  // 上传地址(必填)
+  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) => {
+      try {
+        const data = typeof res === 'string' ? JSON.parse(res) : res
+        return {
+          success: data.code === 200,
+          path: data.data?.path || data.data,
+          message: data.msg || '上传成功',
+          data: data
+        }
+      } catch (e) {
+        return {
+          success: false,
+          path: null,
+          message: '解析响应失败',
+          data: null
+        }
+      }
+    }
+  }
+})
+
+// 定义 emits
+const emit = defineEmits([
+  'input',
+  'update:value',
+  'change',
+  'delete',
+  'success',
+  'fail',
+  'error',
+  'progress'
+])
+
+// 响应式数据
+const innerFileList = ref([])      // 内部文件列表
+const uploadProgress = ref({})     // 存储每个文件的上传进度 { fileIndex: progress }
+const isUploading = ref(false)     // 是否正在上传
+
+// 计算属性
+const remainingLimit = computed(() => {
+  return Math.max(0, props.limit - innerFileList.value.length)
+})
+
+// 监听外部 value 变化
+watch(
+  () => props.value,
+  (newVal) => {
+    innerFileList.value = [...newVal]
+  },
+  { immediate: true, deep: true }
+)
+
+// 监听内部文件列表变化,同步到外部
+watch(
+  innerFileList,
+  (newVal) => {
+    emit('input', newVal)
+    emit('update:value', newVal)
+    emit('change', newVal)
+  },
+  { 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 previewFile = (file) => {
+  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 deleteFile = (index) => {
+  const deleted = 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'
+    })
+  }
+
+  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)
+    }
+  } finally {
+    isUploading.value = false
+  }
+}
+const filePicker = ref(null)
+// 上传文件
+const uploadFile = (fileItem, fileIndex) => {
+  return new Promise((resolve, reject) => {
+    // 构建上传URL
+    const uploadUrl = props.action || (config.Host80 + props.uploadUrl)
+
+    // 构建上传参数
+    const task = uni.uploadFile({
+      url: uploadUrl,
+      filePath: fileItem.path,
+      name: props.uploadName,
+      header: {
+        'Access-Token': userToken.value,
+        ...props.uploadHeaders
+      },
+      formData: props.uploadData,
+      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)
+        } else {
+          uni.showToast({ title: result.message || '上传失败', icon: 'error' })
+          emit('fail', new Error(result.message), fileItem)
+          reject(new Error(result.message))
+        }
+      },
+      fail: (err) => {
+        // 清除进度
+        delete uploadProgress.value[fileIndex]
+
+        console.error('上传失败:', err)
+        uni.showToast({ title: '网络错误,请重试', icon: 'error' })
+        emit('fail', err, fileItem)
+        reject(err)
+      }
+    })
+
+    // 监听上传进度
+    task.onProgressUpdate((progress) => {
+      uploadProgress.value[fileIndex] = progress.progress
+      emit('progress', progress, fileItem, fileIndex)
+    })
+  })
+}
+
+
+
+// 处理失败事件(来自 uni-file-picker)
+const handleFail = (err) => {
+  emit('fail', err)
+}
+
+// 处理错误事件(来自 uni-file-picker)
+const handleError = (err) => {
+  emit('error', err)
+}
+
+// 格式化文件大小
+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]
+}
+
+// 暴露方法(可选,与原组件保持一致,未暴露任何方法)
+// defineExpose({})
+</script>
+
+<style scoped>
+.file-picker-wrapper {
+  width: 100%;
+}
+
+.file-list {
+  margin-bottom: 20rpx;
+}
+
+.file-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #f8f8f8;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  margin-bottom: 16rpx;
+}
+
+.file-info {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  gap: 20rpx;
+}
+
+.file-thumb {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 8rpx;
+  background: #e0e0e0;
+}
+
+.file-icon {
+  width: 80rpx;
+  height: 80rpx;
+  background: #e0e0e0;
+  border-radius: 8rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.file-detail {
+  flex: 1;
+  overflow: hidden;
+}
+
+.file-name {
+  font-size: 28rpx;
+  color: #333;
+  display: block;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.file-size {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+  display: block;
+}
+
+.file-actions {
+  display: flex;
+  gap: 16rpx;
+}
+
+.action-btn {
+  padding: 0 20rpx;
+  height: 56rpx;
+  line-height: 56rpx;
+  font-size: 24rpx;
+}
+
+.readonly-item {
+  cursor: pointer;
+  transition: opacity 0.2s;
+}
+
+.readonly-item:active {
+  opacity: 0.7;
+}
+
+.empty-text {
+  text-align: center;
+  color: #999;
+  font-size: 28rpx;
+  padding: 40rpx 0;
+}
+
+.upload-area {
+  margin-top: 20rpx;
+}
+
+.upload-btn {
+  width: 100%;
+  background: #007aff;
+  color: #fff;
+  border-radius: 8rpx;
+}
+
+/* 上传进度条样式 */
+.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;
+  }
+}
+</style>