|
@@ -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>
|