| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842 |
- <template>
- <view class="common-file-uploader" :class="customClass">
- <!-- 编辑状态:显示可上传的文件选择器 -->
- <view v-if="editable" class="upload-wrapper">
- <uni-file-picker :limit="multiple ? limit : 1" :title="title" :file-mediatype="fileMediatype" :mode="mode"
- :auto-upload="false" :value="fileList" :disabled="disabled" :readonly="readonly"
- :image-styles="imageStyles" :list-styles="listStyles" :del-icon="showPreviewDelete"
- :disablePreview="disablePreview" :canChoose="canChoose" @select="handleSelect" @delete="handleDelete">
- <!-- 自定义上传按钮(单张图片且已有图片时显示替换) -->
- <template v-if="$slots.default || showCustomButton">
- <slot name="default">
- <view class="custom-upload-btn" :class="{ 'replace-btn': !multiple && fileList.length > 0 }"
- :style="customButtonStyle">
- <template v-if="!multiple && fileList.length > 0">
- <text class="replace-icon">↻</text>
- <text class="tip">{{ replaceText || t('Common.Replace') }}</text>
- </template>
- <template v-else>
- <text class="plus">+</text>
- <!-- <text class="tip">{{ uploadText || t('common.upload') }}</text>-->
- </template>
- </view>
- </slot>
- </template>
- </uni-file-picker>
- <!-- 上传进度 -->
- <view v-if="showProgress && uploadProgress > 0 && uploadProgress < 100" class="upload-progress"
- :style="progressStyle">
- <view class="progress-bar" :style="{ width: uploadProgress + '%' }"></view>
- <text class="progress-text">{{ uploadProgress }}%</text>
- </view>
- <!-- 上传错误提示 -->
- <view v-if="uploadError && showError" class="upload-error">
- <text class="error-text">{{ uploadError }}</text>
- </view>
- </view>
- <!-- 非编辑状态:显示图片预览 -->
- <view v-else class="image-preview" :class="previewClass">
- <!-- 单张图片预览 -->
- <template v-if="!multiple">
- <view class="single-preview">
- <image v-if="getImageUrl(modelValue)" :src="getImageUrl(modelValue)" :mode="imageMode"
- class="preview-image" :style="previewImageStyle" @tap="handlePreview(getImageUrl(modelValue))"
- @error="handleImageError" :lazy-load="true" />
- <view v-else class="no-image" :style="noImageStyle">
- <text :style="noImageTextStyle">{{ noImageText || t('Common.NoImage') }}</text>
- </view>
- </view>
- </template>
- <!-- 多张图片预览 -->
- <template v-else>
- <view v-if="getFileList(modelValue).length" class="image-list" :style="listContainerStyle">
- <view v-for="(file, idx) in getFileList(modelValue)" :key="idx" class="image-item"
- :style="imageItemStyle">
- <image :src="getFileUrl(file)" :mode="imageMode" class="preview-image"
- :style="previewImageStyle" @tap="handlePreview(getFilePath(file))" @error="handleImageError"
- :lazy-load="true" />
- <!-- 预览时的删除按钮(如果有权限) -->
- <view v-if="showPreviewDelete && canDelete" class="preview-delete-btn" :style="deleteBtnStyle"
- @tap.stop="handlePreviewDelete(file, idx)">
- <text class="delete-icon">×</text>
- </view>
- </view>
- </view>
- <view v-else class="no-image" :style="noImageStyle">
- <text :style="noImageTextStyle">{{ noImageText || t('Common.NoImage') }}</text>
- </view>
- </template>
- </view>
- </view>
- </template>
- <script setup>
- import { computed, ref, watch } from 'vue'
- import { useI18n } from 'vue-i18n'
- import config from '@/config';
- import { userToken } from '@/composables/config'
- const { t } = useI18n()
- const props = defineProps({
- // 基础配置
- modelValue: {
- type: [String, Array, Object],
- default: null
- },
- editable: {
- type: Boolean,
- default: false
- },
- multiple: {
- type: Boolean,
- default: false
- },
- limit: {
- type: Number,
- default: 1
- },
- // 上传配置
- title: {
- type: String,
- default: ''
- },
- fileMediatype: {
- type: String,
- default: 'image'
- },
- mode: {
- type: String,
- default: 'grid'
- },
- disabled: {
- type: Boolean,
- default: false
- },
- disablePreview: {
- type: Boolean,
- default: false
- },
- canChoose: {
- type: Boolean,
- default: false
- },
- readonly: {
- type: Boolean,
- default: false
- },
- // 上传API配置
- uploadUrl: {
- type: String,
- required: true,
- default: '/custom/bank/upload'
- },
- uploadHeaders: {
- type: Object,
- default: () => ({})
- },
- uploadName: {
- type: String,
- default: 'file'
- },
- uploadData: {
- type: Object,
- default: () => ({})
- },
- // 图片服务配置
- baseUrl: {
- type: String,
- default: config.Host80
- },
- imagePathPrefix: {
- type: String,
- default: ''
- },
- // 样式配置
- customClass: {
- type: String,
- default: ''
- },
- previewClass: {
- type: String,
- default: ''
- },
- // 尺寸配置
- imageWidth: {
- type: [String, Number],
- default: 160
- },
- imageHeight: {
- type: [String, Number],
- default: 160
- },
- imageGap: {
- type: [String, Number],
- default: 16
- },
- imageBorderRadius: {
- type: [String, Number],
- default: 8
- },
- // 图片配置
- imageMode: {
- type: String,
- default: 'aspectFill'
- },
- // 上传按钮配置
- showCustomButton: {
- type: Boolean,
- default: true
- },
- uploadText: {
- type: String,
- default: ''
- },
- replaceText: {
- type: String,
- default: ''
- },
- // 暂无图片配置
- noImageText: {
- type: String,
- default: ''
- },
- // 预览配置
- showPreviewDelete: {
- type: Boolean,
- default: false
- },
- canDelete: {
- type: Boolean,
- default: true
- },
- // 进度显示
- showProgress: {
- type: Boolean,
- default: true
- },
- // 是否自动上传
- autoUpload: {
- type: Boolean,
- default: true
- },
- // 响应数据处理函数
- 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
- }
- }
- }
- },
- // uni-file-picker 的图片样式
- imageStyles: {
- type: Object,
- default: () => ({})
- },
- // uni-file-picker 的列表样式
- listStyles: {
- type: Object,
- default: () => ({})
- },
- // 自定义样式对象
- customStyles: {
- type: Object,
- default: () => ({})
- },
- // 展示错误信息
- showError: {
- type: Object,
- default: true
- }
- })
- const emit = defineEmits([
- 'update:modelValue',
- 'select',
- 'delete',
- 'progress',
- 'success',
- 'fail',
- 'preview',
- 'image-error',
- 'preview-delete',
- 'upload-start',
- 'upload-complete'
- ])
- // 上传进度
- const uploadProgress = ref(0)
- const uploadError = ref('')
- const isUploading = ref(false)
- // 文件列表(用于uni-file-picker回显)
- const fileList = computed(() => {
- return getFileValue(props.modelValue)
- })
- // 计算样式
- const imageWidthPx = computed(() => {
- return typeof props.imageWidth === 'number' ? px2rpx(props.imageWidth) : props.imageWidth
- })
- const imageHeightPx = computed(() => {
- return typeof props.imageHeight === 'number' ? px2rpx(props.imageHeight) : props.imageHeight
- })
- const imageGapPx = computed(() => {
- return typeof props.imageGap === 'number' ? px2rpx(props.imageGap) : props.imageGap
- })
- const imageBorderRadiusPx = computed(() => {
- return typeof props.imageBorderRadius === 'number' ? px2rpx(props.imageBorderRadius) : props.imageBorderRadius
- })
- // 自定义按钮样式
- const customButtonStyle = computed(() => ({
- width: imageWidthPx.value,
- height: imageHeightPx.value,
- ...props.customStyles.customButton
- }))
- // 预览图片样式
- const previewImageStyle = computed(() => ({
- width: imageWidthPx.value,
- height: imageHeightPx.value,
- borderRadius: imageBorderRadiusPx.value,
- ...props.customStyles.previewImage
- }))
- // 图片项样式
- const imageItemStyle = computed(() => ({
- width: imageWidthPx.value,
- height: imageHeightPx.value,
- borderRadius: imageBorderRadiusPx.value,
- ...props.customStyles.imageItem
- }))
- // 列表容器样式
- const listContainerStyle = computed(() => ({
- gap: imageGapPx.value,
- ...props.customStyles.listContainer
- }))
- // 暂无图片样式
- const noImageStyle = computed(() => ({
- width: imageWidthPx.value,
- height: imageHeightPx.value,
- borderRadius: imageBorderRadiusPx.value,
- ...props.customStyles.noImage
- }))
- const noImageTextStyle = computed(() => props.customStyles.noImageText || {})
- const progressStyle = computed(() => props.customStyles.progress || {})
- const deleteBtnStyle = computed(() => props.customStyles.deleteBtn || {})
- // 获取文件值,用于回显
- const getFileValue = (value) => {
- if (!value) return props.multiple ? [] : []
- if (props.multiple) {
- if (Array.isArray(value)) {
- return value.map(item => ({
- url: getFullUrl(item.path || item),
- path: item.path || item,
- name: item.name || '图片',
- uuid: item.id || item.path || Date.now() + Math.random()
- }))
- }
- return []
- } else {
- console.log(value, [{
- url: getFullUrl(value),
- path: value,
- name: '图片',
- uuid: value
- }], 121212121);
- if (typeof value === 'string') {
- return value ? [{
- url: getFullUrl(value),
- path: value,
- name: '图片',
- uuid: value
- }] : []
- }
- if (value && value.path) {
- return [{
- url: getFullUrl(value.path),
- path: value.path,
- name: value.name || '图片',
- uuid: value.id || value.path
- }]
- }
- return []
- }
- }
- // 获取文件列表
- const getFileList = (value) => {
- if (!value) return []
- if (props.multiple) {
- return Array.isArray(value) ? value : []
- } else {
- if (typeof value === 'string') {
- return value ? [{ path: value }] : []
- }
- return value ? [value] : []
- }
- }
- // 获取完整URL
- const getFullUrl = (path) => {
- if (!path) return ''
- if (path.startsWith('http')) return path
- return (props.baseUrl || config.Host80) + (props.imagePathPrefix || '') + path
- }
- // 获取图片URL
- const getImageUrl = (value) => {
- if (!value) return ''
- if (typeof value === 'string') return getFullUrl(value)
- if (value.url) return value.url
- if (value.path) return getFullUrl(value.path)
- return ''
- }
- // 获取文件路径
- const getFilePath = (file) => {
- if (!file) return ''
- if (typeof file === 'string') return file
- return file.path || file.url || ''
- }
- // 获取文件URL(用于预览)
- const getFileUrl = (file) => {
- if (!file) return ''
- if (typeof file === 'string') return getFullUrl(file)
- return getFullUrl(file.path || file.url || '')
- }
- // 处理选择文件
- const handleSelect = async (e) => {
- uploadError.value = ''
- emit('select', e)
- if (props.autoUpload) {
- await uploadFiles(e.tempFiles)
- }
- }
- // 上传文件
- const uploadFiles = async (tempFiles) => {
- if (!tempFiles || tempFiles.length === 0) return
- isUploading.value = true
- emit('upload-start')
- try {
- const uploadedPaths = []
- for (let i = 0; i < tempFiles.length; i++) {
- const file = tempFiles[i]
- uploadProgress.value = Math.round(((i) / tempFiles.length) * 100)
- const result = await uploadSingleFile(file)
- if (result.success) {
- uploadedPaths.push(result.path)
- } else {
- throw new Error(result.message)
- }
- uploadProgress.value = Math.round(((i + 1) / tempFiles.length) * 100)
- }
- // 更新modelValue
- if (props.multiple) {
- // 多张图片:合并新旧数据
- const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
- const newValue = [...currentValue, ...uploadedPaths.map(path => ({ path }))]
- emit('update:modelValue', newValue)
- } else {
- // 单张图片:直接替换
- const newPath = uploadedPaths[0]
- console.log(3333, props.modelValue);
- emit('update:modelValue', newPath)
- }
- uploadProgress.value = 100
- setTimeout(() => {
- uploadProgress.value = 0
- }, 500)
- emit('upload-complete', { success: true, paths: uploadedPaths })
- } catch (error) {
- uploadError.value = error.message || '上传失败'
- uploadProgress.value = 0
- emit('upload-complete', { success: false, error: error.message })
- uni.showToast({
- title: error.message || '上传失败',
- icon: 'none'
- })
- } finally {
- isUploading.value = false
- }
- }
- // 上传单个文件
- const uploadSingleFile = (file) => {
- return new Promise((resolve, reject) => {
- const uploadTask = uni.uploadFile({
- url: config.Host80 + props.uploadUrl,
- filePath: file.path,
- name: props.uploadName,
- header: props.uploadHeaders,
- name: 'file',
- header: {
- 'Access-Token': userToken.value
- },
- formData: props.uploadData,
- success: (res) => {
- const result = props.responseHandler(res.data)
- if (result.success) {
- resolve(result)
- } else {
- reject(new Error(result.message))
- }
- },
- fail: (err) => {
- console.error('上传失败:', err)
- reject(new Error('网络错误,请重试'))
- }
- })
- // 监听上传进度
- uploadTask.onProgressUpdate((res) => {
- // 这里可以处理单个文件的上传进度
- console.log('单文件上传进度:', res.progress)
- })
- })
- }
- // 处理删除文件
- const handleDelete = (e) => {
- emit('delete', e)
- // 更新modelValue
- if (props.multiple) {
- const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
- const newValue = currentValue.filter((_, index) => index !== e.index)
- emit('update:modelValue', newValue)
- } else {
- emit('update:modelValue', '')
- }
- }
- // 处理预览
- const handlePreview = (path) => {
- if (!path) return
- const urls = getFileList(props.modelValue).map(f => getFullUrl(getFilePath(f)))
- uni.previewImage({
- current: getFullUrl(path),
- urls: urls.length ? urls : [getFullUrl(path)]
- })
- emit('preview', path)
- }
- // 处理图片加载错误
- const handleImageError = (e) => {
- console.error('图片加载失败:', e)
- emit('image-error', e)
- }
- // 处理预览时的删除
- const handlePreviewDelete = (file, index) => {
- emit('preview-delete', { file, index })
- // 更新modelValue
- if (props.multiple) {
- const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
- currentValue.splice(index, 1)
- emit('update:modelValue', currentValue)
- }
- }
- // 单位转换函数
- const px2rpx = (px) => {
- // 假设设计稿750px,1px = 2rpx
- return px * 2 + 'rpx'
- }
- // 对外暴露方法
- defineExpose({
- uploadFiles,
- clearError: () => uploadError.value = ''
- })
- </script>
- <style lang="scss" scoped>
- .common-file-uploader {
- width: 100%;
- .upload-wrapper {
- width: 100%;
- height: 100%;
- :deep(.uni-file-picker) {
- .file-picker__box {
- border: none;
- background: transparent;
- }
- .uni-file-picker__files {
- height: 100%;
- }
- .files-button {
- height: 100%;
- }
- .custom-upload-btn {
- width: 100% !important;
- height: 100% !important;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- &.replace-btn {
- background: rgba(0, 0, 0, 0.03);
- .replace-icon {
- font-size: 40px;
- color: var(--bs-heading-color);
- margin-bottom: 4px;
- transform: rotate(90deg);
- }
- .tip {
- color: var(--bs-heading-color);
- }
- &:hover {
- background: rgba(234, 32, 39, 0.05);
- .replace-icon,
- .tip {
- color: #ea2027;
- }
- }
- }
- .plus {
- font-size: 48px;
- color: #9ca3af;
- //line-height: 1;
- }
- .tip {
- font-size: 24px;
- color: #6b7280;
- }
- }
- .file-picker__box-list {
- display: flex;
- gap: v-bind(imageGapPx);
- flex-wrap: wrap;
- .file-picker__box-item {
- position: relative;
- border: 1px solid #e5e7eb;
- overflow: hidden;
- .file-picker__box-item-image {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- .file-picker__box-item-close {
- position: absolute;
- top: 4px;
- right: 4px;
- width: 24px;
- height: 24px;
- background: rgba(0, 0, 0, 0.5);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- &::before {
- content: '×';
- color: var(--bs-emphasis-color);
- font-size: 28px;
- line-height: 1;
- }
- .close-icon {
- display: none;
- }
- }
- }
- }
- .file-picker__box-add {
- margin: 0;
- border: 2rpx dashed #d1d5db;
- background: #f9fafb;
- transition: all 0.3s;
- cursor: pointer;
- &:hover {
- border-color: #ea2027;
- background: #fef2f2;
- }
- .add-icon {
- display: none;
- }
- }
- .file-picker__box-progress {
- border-radius: v-bind(imageBorderRadiusPx);
- background: rgba(0, 0, 0, 0.5);
- color: var(--bs-emphasis-color);
- }
- }
- .upload-progress {
- margin-top: 8px;
- padding: 8px;
- background: #f5f5f5;
- border-radius: 4px;
- position: relative;
- .progress-bar {
- height: 4px;
- background: #ea2027;
- border-radius: 2px;
- transition: width 0.3s;
- }
- .progress-text {
- position: absolute;
- right: 8px;
- top: 8px;
- font-size: 24px;
- color: var(--bs-heading-color);
- }
- }
- .upload-error {
- margin-top: 8px;
- padding: 8px;
- background: #fee2e2;
- border-radius: 4px;
- .error-text {
- color: #dc2626;
- font-size: 24px;
- }
- }
- }
- .image-preview {
- .image-list {
- display: flex;
- gap: v-bind(imageGapPx);
- flex-wrap: wrap;
- .image-item {
- position: relative;
- overflow: hidden;
- border: 1px solid #e5e7eb;
- .preview-image {
- width: 100%;
- height: 100%;
- cursor: pointer;
- }
- .preview-delete-btn {
- position: absolute;
- top: 4px;
- right: 4px;
- width: 24px;
- height: 24px;
- background: rgba(0, 0, 0, 0.5);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- .delete-icon {
- color: var(--bs-emphasis-color);
- font-size: 28px;
- line-height: 1;
- }
- }
- }
- }
- .single-preview {
- .preview-image {
- cursor: pointer;
- border: 1px solid #e5e7eb;
- }
- }
- .no-image {
- display: flex;
- align-items: center;
- justify-content: center;
- background: #f5f5f5;
- border: 1px solid #e5e7eb;
- text {
- color: var(--bs-heading-color);
- font-size: 24px;
- }
- }
- }
- }
- </style>
|