| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940 |
- <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 cursor-pointer" 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 cursor-pointer"
- :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>
- <cop-chooseFile :trigger="triggerFlag" accept="*" @receiveRenderFile="handleFile">
- </cop-chooseFile>
- </template>
- <script setup>
- import { ref, watch, nextTick, computed } from 'vue'
- import config from '@/config'
- import { userToken } from '@/composables/config'
- import copChooseFile from '@/uni_modules/cop-chooseFile/components/cop-chooseFile/cop-chooseFile.vue'
- // === 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,Number],
- default: ''
- },
- imageHeight: {
- type: [String,Number],
- 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 triggerFlag = ref(0)
- // 内部数据
- 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.Host05 + 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.Host05 + 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.Host05 + 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.Host05 + 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 || '')
- // 按原始类型返回
- 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
- // #ifdef H5
- 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()
- }
- })
- // #endif
- // #ifdef APP-PLUS
- triggerFlag.value = Date.now()
- // chooseFileFromModule({
- // complete: (res) => {
- // console.log(res)
- // let path = res.path
- // let name = res.name
- // let fileType = ''
- // // 如果没有name,默认为:截取最后一个/之后的内容
- // if (!name) {
- // let lastIndex = path.lastIndexOf('/');
- // if (lastIndex !== -1) {
- // name = path.substring(lastIndex + 1);
- // } else {
- // name = Math.random().toString(36).substr(2) + Date.now();
- // }
- // }
- // // 使用 lastIndexOf 方法找到最后一个 . 的位置
- // let lastDotIndex = name.lastIndexOf('.');
- // if (lastDotIndex !== -1) {
- // fileType = name.substring(lastDotIndex + 1);
- // } else {
- // console.log('文件路径中没有 .');
- // }
- // let file = {
- // size: res.size,
- // path,
- // fileType,
- // name
- // }
- // console.log('根据需求构造的数据', file)
- // }
- // })
- // #endif
- }
- // 处理选择的文件
- const handleFile = async (fileData) => {
- try {
- const tempPath = await base64ToTempFile(
- fileData.filePath,
- fileData.name
- )
- const file = {
- name: fileData.name,
- size: fileData.size,
- type: fileData.type,
- // 关键
- path: tempPath,
- status: 'ready'
- }
- console.log(file, 121212);
- tempFileQueue.value = [file]
- emit('select', {
- tempFiles: [file]
- })
- if (props.autoUpload) {
- startUpload()
- }
- } catch (e) {
- uni.showToast({
- title: '文件处理失败',
- icon: 'none'
- })
- console.error(e)
- }
- }
- const startUpload = async () => {
- const files = tempFileQueue.value
- tempFileQueue.value = []
- for (const file of files) {
- // APP base64
- if (file.base64) {
- await uploadBase64(file)
- }
- // H5 正常文件
- else {
- await uploadFile(file)
- }
- }
- }
- const uploadFile = (fileItem) => {
- return new Promise((resolve) => {
- innerFileList.value.push({ ...fileItem, status: 'uploading', progress: 0 })
- console.log(innerFileList.value,'upload231')
- const index = innerFileList.value.length - 1
- const url = props.action || config.Host80 + props.uploadUrl
- console.log({
- url,
- filePath: fileItem.path,
- name: props.uploadName,
- header: { 'Access-Token': userToken.value, ...props.uploadHeaders },
- formData: props.uploadData
- }, 100000);
- 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 = typeof props.responseHandler === 'function'
- ? props.responseHandler(res.data)
- : {
- success: typeof res.data === 'string'
- ? JSON.parse(res.data).code === 200
- : res.data.code === 200,
- path: typeof res.data === 'string'
- ? (JSON.parse(res.data).data?.path || JSON.parse(res.data).data)
- : (res.data.data?.path || res.data.data),
- message: typeof res.data === 'string'
- ? JSON.parse(res.data).msg
- : res.data.msg
- }
- console.log(innerFileList.value,'file',result)
- if (result.success) {
- innerFileList.value[index].progress = 100
- innerFileList.value[index].status = 'success'
- innerFileList.value[index].url = config.Host05 + 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 uploadBase64 = (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
- uni.request({
- url,
- method: 'POST',
- header: {
- 'Content-Type': 'application/json',
- 'Access-Token': userToken.value,
- ...props.uploadHeaders
- },
- data: {
- file: fileItem.base64,
- fileName: fileItem.name,
- fileType: fileItem.type,
- ...props.uploadData
- },
- success: (res) => {
- const result =
- typeof props.responseHandler === 'function'
- ? props.responseHandler(res.data)
- : {
- success: res.data.code === 200,
- path: res.data.data?.path || res.data.data,
- message: res.data.msg
- }
- if (result.success) {
- innerFileList.value[index].progress = 100
- innerFileList.value[index].status = 'success'
- // 这里继续用后端返回URL
- innerFileList.value[index].url =
- config.Host05 + 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: 'none'
- })
- emit('fail', result.message)
- }
- resolve(result)
- },
- fail: () => {
- innerFileList.value[index].status = 'error'
- uni.showToast({
- title: '上传失败',
- icon: 'none'
- })
- emit('fail', '网络异常')
- resolve(null)
- }
- })
- })
- }
- const base64ToTempFile = (base64, fileName = 'file.png') => {
- return new Promise((resolve, reject) => {
- const matches = base64.match(/^data:(.+);base64,(.+)$/)
- if (!matches) {
- reject('base64格式错误')
- return
- }
- const base64Data = matches[2]
- const filePath =
- `${plus.io.convertLocalFileSystemURL('_doc/')}${Date.now()}_${fileName}`
- plus.io.resolveLocalFileSystemURL(
- '_doc/',
- (entry) => {
- entry.getFile(
- `${Date.now()}_${fileName}`,
- { create: true },
- (fileEntry) => {
- fileEntry.createWriter((writer) => {
- writer.onwrite = () => {
- resolve(fileEntry.toLocalURL())
- }
- writer.onerror = reject
- const bitmap = new plus.nativeObj.Bitmap()
- bitmap.loadBase64Data(
- base64,
- () => {
- bitmap.save(
- fileEntry.toLocalURL(),
- {},
- () => {
- resolve(fileEntry.toLocalURL())
- },
- reject
- )
- },
- reject
- )
- })
- },
- reject
- )
- },
- reject
- )
- })
- }
- 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 lang="scss">
- .file-picker-wrapper{
- }
- /* 布局 */
- .uni-file-picker__container {
- display: flex;
- flex-wrap: wrap;
- //margin: -5px;
- justify-content: center;
- align-self: center;
- gap: px2rpx(5);
- 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>
|