| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651 |
- <template>
- <view class="file-picker-wrapper">
- <!-- 只读模式:仅展示 -->
- <view v-if="readonly" class="file-list readonly-list">
- <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" :style="imgStyle" />
- <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="!innerFileList?.length" class="empty-text">暂无文件</view>
- </view>
- <!-- 正常模式:宫格上传(完全对齐官方 upload-image 样式) -->
- <view v-else class="uni-file-picker__container">
- <view class="file-picker__box" v-for="(item, index) in innerFileList" :key="index" :style="typeof boxStyle === 'object' ? boxStyle : { cssText: boxStyle }">
- <view class="file-picker__box-content" :style="borderStyle">
- <!-- 图片 -->
- <image v-if="isImage(item)" class="file-image" :src="item.url || item.path" mode="aspectFill" :style="imgStyle"
- @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" :style="imgStyle" />
- <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="typeof boxStyle === 'object' ? boxStyle : { cssText: boxStyle }">
- <view class="file-picker__box-content is-add" :style="borderStyle" @click="handleChoose">
- <cwg-icon name="icon_add" class="upload-icon" :size="24" />
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup>
- import { ref, watch, nextTick,computed } from 'vue'
- import config from '@/config'
- import { userToken } from '@/composables/config'
- // === Vue3 v-model 标准写法 + 多类型兼容 ===
- const props = defineProps({
- modelValue: {
- type: [Array, String, Object],
- default: () => []
- },
- readonly: {
- type: Boolean,
- default: false
- },
- disabled: {
- type: Boolean,
- default: false
- },
- limit: {
- type: Number,
- default: 9
- },
- fileMediatype: {
- type: String,
- default: 'all'
- },
- delIcon: {
- type: Boolean,
- default: true
- },
- disablePreview: {
- type: Boolean,
- default: false
- },
- autoUpload: {
- type: Boolean,
- default: true
- },
- action: {
- type: String,
- default: ''
- },
- imageWidth: {
- type: String,
- default: ''
- },
- imageHeight: {
- type: String,
- default: ''
- },
- 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 || '上传成功'
- }
- } catch (e) {
- return { success: false, message: '解析失败' }
- }
- }
- }
- })
- const emit = defineEmits([
- 'update:modelValue',
- 'change',
- 'delete',
- 'success',
- 'fail',
- 'progress',
- 'select'
- ])
- // 内部数据
- const innerFileList = ref([])
- const tempFileQueue = ref([])
- const originalType = ref('array') // 'string' | 'object' | 'array'
- const borderStyle = 'border:1px #eee solid;border-radius:5px;'
- const boxStyle = computed(() => {
- if (props.imageWidth || props.imageHeight) {
- const width = typeof props.imageWidth === 'number' ? `${props.imageWidth}px` : props.imageWidth || 'auto'
- const height = typeof props.imageHeight === 'number' ? `${props.imageHeight}px` : props.imageHeight || 'auto'
- return { width, height }
- }
- return 'width:33.3%;height:0;'
- })
- // ==============================================
- // 统一格式化函数(修复递归核心)
- // ==============================================
- function formatValue(val) {
- if (!val) return []
- // 字符串
- if (typeof val === 'string') {
- return [{
- path: val,
- url: val.startsWith('http') ? val : config.Host80 + val,
- name: val.split('/').pop(),
- status: 'success'
- }]
- }
- // 对象
- if (typeof val === 'object' && !Array.isArray(val)) {
- const path = val.path || val.url || ''
- return [{
- ...val,
- path,
- url: val.url || (path.startsWith('http') ? path : config.Host80 + path),
- name: val.name || path.split('/').pop(),
- status: 'success'
- }]
- }
- // 数组
- if (Array.isArray(val)) {
- return val.map(item => {
- if (typeof item === 'string') {
- return {
- path: item,
- url: item.startsWith('http') ? item : config.Host80 + item,
- name: item.split('/').pop(),
- status: 'success'
- }
- } else {
- const path = item?.path || item?.url || ''
- return {
- ...item,
- path,
- url: item?.url || (path.startsWith('http') ? path : config.Host80 + path),
- name: item?.name || path.split('/').pop(),
- status: 'success'
- }
- }
- })
- }
- return []
- }
- // ==============================================
- // 监听外部传入:防重复赋值 → 无递归
- // ==============================================
- watch(
- () => props.modelValue,
- (val) => {
- const formatted = formatValue(val)
- // 值相同不更新,防止死循环
- if (JSON.stringify(innerFileList.value) === JSON.stringify(formatted)) return
- if (!val) {
- innerFileList.value = []
- originalType.value = 'array'
- return
- }
- // 记录原始类型
- if (typeof val === 'string') originalType.value = 'string'
- else if (typeof val === 'object' && !Array.isArray(val)) originalType.value = 'object'
- else originalType.value = 'array'
- innerFileList.value = formatted
- },
- { immediate: true, deep: true }
- )
- // ==============================================
- // 内部变化同步外部:nextTick → 无递归
- // ==============================================
- watch(
- innerFileList,
- (list) => {
- nextTick(() => {
- let returnValue = []
- if (!list || list.length === 0) {
- returnValue = originalType.value === 'string' ? '' :
- originalType.value === 'object' ? {} : []
- emitUpdate(returnValue)
- return
- }
- // 只返回干净的 path 数据
- const cleanList = list.map(item => item.path || item.url || '')
- console.log(originalType);
- // 按原始类型返回
- if (props.limit === 1) {
- returnValue = cleanList[0] || ''
- } else if (originalType.value === 'string') returnValue = cleanList[0] || ''
- else if (originalType.value === 'object') returnValue = { path: cleanList[0] || '' }
- else returnValue = cleanList
- emitUpdate(returnValue)
- })
- },
- { deep: true }
- )
- // 统一触发更新
- function emitUpdate(val) {
- emit('update:modelValue', val)
- emit('change', val)
- }
- // ==============================================
- // 文件类型判断
- // ==============================================
- const isImage = (file) => {
- 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 (['DOC', 'DOCX'].includes(ext)) return 'WORD'
- if (['XLS', 'XLSX'].includes(ext)) return 'EXCEL'
- if (isVideo({ name })) return '▶'
- if (isImage({ name })) return 'IMG'
- return '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'
- }
- // ==============================================
- // 预览
- // ==============================================
- 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 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, loop: true })
- return
- }
- uni.navigateTo({
- url: `/pages/common/webview?url=${encodeURIComponent(url)}&title=${encodeURIComponent(name)}`
- })
- }
- // ==============================================
- // 上传 / 删除 / 重试
- // ==============================================
- const deleteFile = (index) => {
- const item = innerFileList.value[index]
- innerFileList.value.splice(index, 1)
- emit('delete', item, index)
- }
- 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()
- }
- })
- }
- const startUpload = async () => {
- const files = tempFileQueue.value
- tempFileQueue.value = []
- for (const file of files) await uploadFile(file)
- }
- 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,
- 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) {
- 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 {
- innerFileList.value[index].status = 'error'
- uni.showToast({ title: result.message, icon: 'error' })
- emit('fail', result.message)
- }
- resolve(result)
- },
- fail: () => {
- innerFileList.value[index].status = 'error'
- uni.showToast({ title: '上传失败', icon: 'error' })
- emit('fail', '网络异常')
- resolve(null)
- }
- })
- task.onProgressUpdate((p) => {
- innerFileList.value[index].progress = p.progress
- emit('progress', p, index)
- })
- })
- }
- const reUploadFile = (index) => {
- const file = innerFileList.value[index]
- uploadFile(file)
- }
- const imgStyle = computed(() => {
- let style = {}
- if (props.imageWidth) {
- style.width = typeof props.imageWidth === 'number' ? `${props.imageWidth}px` : `${props.imageWidth}`
- }
- if (props.imageHeight) {
- style.height = typeof props.imageHeight === 'number' ? `${props.imageHeight}px` : `${props.imageHeight}`
- }
- console.log(style)
- return style
- })
- </script>
- <style scoped>
- /* 布局 */
- .uni-file-picker__container {
- display: flex;
- flex-wrap: wrap;
- //margin: -5px;
- width: 100%;
- }
- .file-picker__box {
- position: relative;
- width: 33.3%;
- height: 0;
- //padding-top: 33.3%;
- box-sizing: border-box;
- }
- .file-picker__box-content {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- //margin: 5px;
- border-radius: 5px;
- overflow: hidden;
- background: #f7f7f7;
- }
- /* 图片 */
- .file-image {
- width: 100%;
- height: 100%;
- }
- /* 文件封面 */
- .file-cover {
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- color: var(--bs-emphasis-color);
- }
- /* 视频 */
- .video-box {
- position: relative;
- }
- .video-play-icon {
- position: absolute;
- font-size: 30px;
- color: var(--bs-emphasis-color);
- background: rgba(0, 0, 0, 0.5);
- width: 50px;
- height: 50px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- /* 文件类型颜色 */
- .file-pdf {
- background: #ff4f4f;
- }
- .file-word {
- background: #3a7ff5;
- }
- .file-excel {
- background: #24a148;
- }
- .file-video {
- background: #000;
- }
- .file-other {
- background: #f7f7f7;
- }
- .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;
- justify-content: center;
- background: var(--bs-body-bg);
- }
- .upload-icon {
- color: var(--bs-heading-color);
- }
- /* 删除按钮 */
- .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);
- }
- .icon-del {
- width: 15px;
- height: 2px;
- background: #fff;
- border-radius: 2px;
- }
- .rotate {
- position: absolute;
- transform: rotate(90deg);
- }
- /* 进度 */
- .file-picker__progress {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: 2;
- }
- /* 失败遮罩 */
- .file-picker__mask {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.4);
- color: var(--bs-emphasis-color);
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- }
- /* 只读模式 */
- .file-list {
- padding: 5px;
- }
- .file-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px;
- background: #f8f8f8;
- border-radius: 8px;
- margin-bottom: 10px;
- }
- .file-thumb,
- .file-icon {
- width: 60px;
- height: 60px;
- border-radius: 6px;
- }
- .file-icon {
- background: #eee;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .file-icon-text {
- font-size: 12px;
- font-weight: bold;
- }
- .file-name {
- flex: 1;
- font-size: 14px;
- color: var(--bs-heading-color);
- }
- .empty-text {
- text-align: center;
- color: var(--bs-heading-color);
- padding: 20px 0;
- }
- </style>
|