2025/2/8第一次更新

This commit is contained in:
爱吃咸鱼小猫咪
2025-02-08 18:50:38 +08:00
commit d7af560866
26519 changed files with 5046029 additions and 0 deletions
@@ -0,0 +1,9 @@
import { withNoopInstall } from '../../utils'
import ImageUpload from './src/image-upload.vue'
export const TnImageUpload = withNoopInstall(ImageUpload)
export default TnImageUpload
export * from './src/image-upload'
export * from './src/types'
export type { TnImageUploadInstance } from './src/instance'
@@ -0,0 +1 @@
export * from './use-image-upload'
@@ -0,0 +1,377 @@
import { computed, getCurrentInstance, nextTick, ref, watch } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { debugWarn, isBoolean, isPromise, throwError } from '../../../../utils'
import { useFormItem } from '../../../form'
import useUploadHandleFunction from './use-upload-handle-function'
import type { ImageUploadList, ImageUploadListItem } from '../types'
import type { ImageUploadProps } from '../image-upload'
export const useImageUpload = (props: ImageUploadProps) => {
const { emit } = getCurrentInstance()!
const {
chooseImage,
uploadProcess,
checkFileSizeAndExtension,
showErrorTips,
} = useUploadHandleFunction(props)
const { formItem } = useFormItem()
// 文件列表
const fileList = ref<ImageUploadList>([])
// 监听文件列表变化
let isInnerUpdate = false
watch(
() => props.modelValue,
(val) => {
if (isInnerUpdate) {
isInnerUpdate = false
return
}
fileList.value = val.map((item) => ({
url: item,
status: 'done',
progress: 100,
}))
},
{
immediate: true,
}
)
// 判断是否超过最大上传数
const isExceedMaxCount = computed<boolean>(
() => fileList.value.length >= props.limit
)
// 当前剩余可选文件数量
const currentRemainFileCount = computed<number>(() => {
if (props.multiple) {
return props.limit - fileList.value.length
} else {
return props.limit - fileList.value.length > 0 ? 1 : 0
}
})
// 选择文件
const chooseFile = async () => {
const { disabled, action, customUploadHandler } = props
if (disabled) return
// 如果没有设置action或者没有自定义图片上传处理函数,则直接返回
if (!action && !customUploadHandler) {
showErrorTips('请设置action或者自定义图片上传处理函数')
debugWarn('TnImageUpload', '请设置action或者自定义图片上传处理函数')
return
}
// 选择前已有文件的数量
const prevUploadedFileCount = fileList.value.length
chooseImage(currentRemainFileCount.value)
.then((res) => {
let selectFile = res
// 判断尺寸和格式是否正确
const checkFailFiles = checkFileSizeAndExtension(selectFile)
if (checkFailFiles.length) {
showErrorTips('文件格式或大小不符合要求')
emit('oversizeOrNoSupport', checkFailFiles)
// 剔除不符合要求的文件
selectFile = selectFile.filter(
(item) => !checkFailFiles.includes(item)
)
}
fileList.value.push(
...selectFile.map<ImageUploadListItem>((item) => {
const url = (item as UniApp.ChooseImageSuccessCallbackResultFile)
.path
return {
url,
status: 'ready',
progress: 0,
file: item,
}
})
)
if (props.autoUpload && selectFile.length)
uploadFile(prevUploadedFileCount)
})
.catch((err) => {
debugWarn('TnImageUpload', `选择图片失败: ${err}`)
showErrorTips(err?.errMsg || '选择图片失败')
})
}
// 处理上传事件
const handleUploadEvent = (
item: ImageUploadListItem,
index: number,
uploadSingle = false
) => {
uploadProcess(item)
.then((res) => {
if (res) {
handleUploadSuccess(item)
} else {
handleUploadError(item, '上传失败')
}
})
.catch((err) => {
handleUploadError(item, err)
})
.finally(() => {
if (!uploadSingle) uploadFile(index + 1)
})
}
// 上传文件
const uploadFile = (startIndex: number, uploadSingle = false) => {
const { autoUpload, beforeUpload } = props
const autoNextUpload = autoUpload && !uploadSingle
// 判断是否全部文件上传完毕
if (startIndex >= fileList.value.length) {
if (props.autoRemoveFaildFile) handleUploadCompleteFailFile()
return
}
const fileItem = fileList.value[startIndex]
// 如果当前上传完毕自动上传下一张
if (fileItem.progress === 100) {
fileItem.status = 'done'
fileItem.uploadTask = undefined
if (autoNextUpload) uploadFile(startIndex + 1)
return
}
// 上传前回调
if (!beforeUpload) {
handleUploadEvent(fileItem, startIndex, uploadSingle)
return
}
const shouldUpload = beforeUpload(fileItem.file!)
const isBeforeUploadPromiseOrBoolean = [
isPromise(shouldUpload),
isBoolean(shouldUpload),
].includes(true)
if (!isBeforeUploadPromiseOrBoolean) {
throwError(
'[TnImageUpload]',
'beforeUpload返回值必须是Promise或者Boolean'
)
}
if (isPromise(shouldUpload)) {
shouldUpload
.then((res) => {
if (res) handleUploadEvent(fileItem, startIndex, uploadSingle)
else {
removeFile(startIndex)
if (autoNextUpload) uploadFile(startIndex)
}
})
.catch((err) => {
debugWarn('TnImageUpload', `beforeUpload出错: ${err}`)
fileItem.status = 'failed'
})
} else {
if (shouldUpload) handleUploadEvent(fileItem, startIndex, uploadSingle)
else {
removeFile(startIndex)
if (autoNextUpload) uploadFile(startIndex)
}
}
}
// 获取上传成功的文件url
const getUploadSuceesFileUrlValue = () => {
return fileList.value
.filter((item) => item.status === 'done')
.map((item) => item.url)
}
// 已上传文件列表发生改变
const uploadSuccessFileListChange = () => {
isInnerUpdate = true
const value = getUploadSuceesFileUrlValue()
emit(UPDATE_MODEL_EVENT, value)
nextTick(() => {
emit(CHANGE_EVENT, value)
if (props.validateEvent) {
formItem?.validate?.('change').catch((err) => {
debugWarn(err)
})
}
})
}
// 处理文件上传成功
const handleUploadSuccess = (item: ImageUploadListItem) => {
item.status = 'done'
item.progress = 100
item.uploadTask = undefined
item.file = undefined
emit('success', item)
uploadSuccessFileListChange()
}
// 处理上传文件发生错误
const handleUploadError = (item: ImageUploadListItem, errorMsg: string) => {
item.status = 'failed'
item.progress = 0
item.uploadTask = undefined
item.file = undefined
showErrorTips(errorMsg)
emit('fail', new Error(errorMsg), item)
}
// 处理上传完成后失败的文件
const handleUploadCompleteFailFile = () => {
const tempFileList = [...fileList.value]
tempFileList.forEach((item, index) => {
if (item.status === 'failed') {
removeFile(index)
}
})
}
// 重新上传文件
const retryUploadFile = (index: number) => {
const fileItem = fileList.value[index]
fileItem.status = 'ready'
fileItem.progress = 0
uploadFile(index, true)
}
// 重新上传全部文件
const retryAllUpload = () => {
// 查找出第一张上传失败的图片
const firstFailedFileIndex = fileList.value.findIndex(
(item) => item.status === 'failed'
)
uploadFile(firstFailedFileIndex)
}
// 手动上传文件
const customUploadHandle = () => {
if (!fileList.value.length) return
uploadFile(0)
}
// 移除文件
const removeFile = (index: number) => {
const fileItem = fileList.value[index]
// 如果文件正在上传中,取消上传
if (
fileItem.status === 'uploading' &&
fileItem.uploadTask &&
fileItem.progress > 0 &&
fileItem.progress < 100
) {
fileItem.uploadTask.abort()
}
fileList.value.splice(index, 1)
// 如果文件是已经完成的,重新计算上传成功的文件url
if (fileItem.status === 'done') {
emit('remove', fileItem.url)
uploadSuccessFileListChange()
}
}
// 删除文件
const removeFileEvent = (index: number) => {
const { disabled, beforeRemove } = props
if (disabled) return
// 获取待删除的文件
const fileItem = fileList.value[index]
if (!fileItem) return
uni.showModal({
title: '操作提示',
content: '确认需要移除该图片吗?',
showCancel: true,
cancelText: '取 消',
confirmText: '确 认',
success: (res) => {
if (res.confirm) {
// 删除前回调
if (!beforeRemove) {
removeFile(index)
return
}
const shouldRemove = beforeRemove(fileItem)
const isShouldRemovePromiseOrBoolean = [
isPromise(shouldRemove),
isBoolean(shouldRemove),
].includes(true)
if (!isShouldRemovePromiseOrBoolean) {
throwError(
'[TnImageUpload]',
'beforeRemove返回值必须是Promise或者Boolean'
)
}
if (isPromise(shouldRemove)) {
shouldRemove
.then((res) => {
if (res) removeFile(index)
})
.catch((err) => {
debugWarn('TnImageUpload', `beforeRemove出错: ${err}`)
})
} else {
if (shouldRemove) removeFile(index)
}
}
},
})
}
// 清空文件列表
const clearAllFile = () => {
// 如果文件正在上传中,取消上传
fileList.value.forEach((item) => {
if (
item.status === 'uploading' &&
item.uploadTask &&
item.progress > 0 &&
item.progress < 100
) {
item.uploadTask.abort()
}
})
fileList.value = []
uploadSuccessFileListChange()
}
// 点击图片预览图片
const previewImage = (index: number) => {
const previewImageList = fileList.value
.filter((item) => item.status === 'done')
.map((item) => item.url)
uni.previewImage({
current: index,
urls: previewImageList,
})
emit('preview', previewImageList[index])
}
return {
fileList,
isExceedMaxCount,
chooseFile,
retryUploadFile,
retryAllUpload,
customUploadHandle,
removeFileEvent,
clearAllFile,
previewImage,
}
}
@@ -0,0 +1,230 @@
import { ref, watch } from 'vue'
import {
isArray,
isEmptyVariableInDefault,
isPromise,
isString,
} from '../../../../utils'
import { isJsonString } from '../../../../utils/validator'
import type { ImageUploadFile, ImageUploadListItem } from '../types'
import type { ImageUploadProps } from '../image-upload'
export default function useUploadHandleFunction(props: ImageUploadProps) {
// 选择系统图片
const chooseImage = (count: number) => {
return new Promise<Array<ImageUploadFile>>((resolve, reject) => {
uni.chooseImage({
count,
sizeType: props.sizeType,
// extension: props.extensions,
sourceType: props.sourceType,
success: (res) => {
resolve(isArray(res.tempFiles) ? res.tempFiles : [res.tempFiles])
},
fail: (err) => {
reject(err)
},
})
})
}
// 上传图片事件,调用接口
// 正在上传标识
const uploading = ref(false)
watch(
() => uploading.value,
(val) => {
if (props.showErrorTips) {
if (val) uni.showLoading({ title: '上传中' })
else uni.hideLoading()
}
}
)
const uploadProcess = (item: ImageUploadListItem): Promise<boolean> => {
const { customUploadHandler, customUploadCallback } = props
if (uploading.value) return Promise.reject('有文件正在上传')
return new Promise((resolve, reject) => {
// 如果有自定义上传处理函数,则调用自定义上传处理函数
if (customUploadHandler) {
const uploadHandlerResult = customUploadHandler(item.file!)
const isUploadHandlePromiseOrString = [
isPromise(uploadHandlerResult),
isString(uploadHandlerResult),
].includes(true)
if (!isUploadHandlePromiseOrString) {
console.error(
'[TnImageUpload]自定义上传处理函数必须返回Promise和String'
)
reject('自定义上传处理函数必须返回Promise和String')
return
}
uploading.value = true
item.status = 'uploading'
if (isPromise(uploadHandlerResult)) {
uploadHandlerResult
.then((res) => {
if (res) {
item.url = res
resolve(true)
} else {
resolve(false)
}
})
.catch((err) => {
console.error('[TnImageUpload]上传文件发生错误', err)
reject(err?.errMsg || '上传文件发生错误')
})
.finally(() => {
uploading.value = false
})
} else {
if (uploadHandlerResult) {
item.url = uploadHandlerResult
resolve(true)
} else {
resolve(false)
}
uploading.value = false
}
} else {
// 内部集成图片上传
uploading.value = true
item.status = 'uploading'
// 创建上传对象
const task = uni.uploadFile({
url: props.action!,
filePath: item.url,
name: props.name,
formData: props.formData,
header: props.header,
success: (res: UniApp.UploadFileSuccessCallbackResult) => {
// 判断用户是否自己处理上传后服务器回传的结果
if (customUploadCallback) {
const customUploadCallbackResult = customUploadCallback(res)
const isCustomUploadCallbackPromiseOrString = [
isPromise(customUploadCallbackResult),
isString(customUploadCallbackResult),
].includes(true)
if (!isCustomUploadCallbackPromiseOrString) {
console.error(
'[TnImageUpload]自定义上传回调函数必须返回Promise和String'
)
reject('自定义上传回调函数必须返回Promise和String')
return
}
if (isPromise(customUploadCallbackResult)) {
customUploadCallbackResult
.then((res) => {
if (res) {
item.url = res
resolve(true)
} else {
resolve(false)
}
})
.catch((err) => {
console.error('[TnImageUpload]上传文件发生错误', err)
reject(err?.errMsg || '上传文件发生错误')
})
} else {
if (customUploadCallbackResult) {
item.url = customUploadCallbackResult
resolve(true)
} else {
resolve(false)
}
}
} else {
// 使用集成的上传回调函数
const { statusCode, data: resData } = res
if (![200, 201, 204].includes(statusCode)) {
console.error('[TnImageUpload]上传文件发生错误', res)
reject(res?.errMsg || '上传文件发生错误')
return
} else {
const data = isJsonString(resData)
? JSON.parse(resData)
: resData
// 默认返回的格式为 { code: 200, data: { errorCode: 0, url: '' } }
if (data.code === 200 && data.data.errCode === 0) {
item.url = data.data.url
resolve(true)
} else {
console.error('[TnImageUpload]上传文件发生错误', res)
reject(
isEmptyVariableInDefault(
data?.message,
data?.msg || '上传文件发生错误'
)
)
}
}
}
},
fail: (err) => {
console.error('[TnImageUpload]上传文件发生错误', err)
reject(err?.errMsg || '上传文件发生错误')
},
complete: () => {
uploading.value = false
resolve(true)
},
})
item.uploadTask = task
// 监听上传进度
task.onProgressUpdate((res) => {
if (res.progress > 0) {
item.progress = res.progress
}
})
}
})
}
// 检查文件是否超过最大文件尺寸或者不符合上传文件类型
const checkFileSizeAndExtension = (files: Array<ImageUploadFile>) => {
const { extensions, maxSize } = props
// #ifdef MP-ALIPAY
extensions.push('image')
// #endif
const extReg = /.+\./
return files.filter((item) => {
// 获取文件后缀名
let fileExt = ''
// #ifdef H5
fileExt = (item as File).name.replace(extReg, '').toLowerCase()
// #endif
// #ifndef H5
fileExt = (item as UniApp.ChooseImageSuccessCallbackResultFile).path
.replace(extReg, '')
.toLowerCase()
// #endif
return (
!extensions.some((ext) => ext.toLowerCase() === fileExt) ||
item.size > maxSize
)
})
}
// 是否显示错误提示
const showErrorTips = (msg: string) => {
if (!props.showErrorTips) return
uni.showToast({
icon: 'none',
title: msg,
})
}
return {
chooseImage,
uploadProcess,
checkFileSizeAndExtension,
showErrorTips,
}
}
@@ -0,0 +1,215 @@
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import { buildProps, definePropType, isArray } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
import type { ImageUploadFile, ImageUploadListItem } from './types'
export const imageUploadSizeTypes = ['original', 'compressed'] as const
export const imageUploadSources = ['album', 'camera'] as const
export const imageExtensions = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'ico',
] as const
export type ImageUploadCustomFunction = (
file: ImageUploadFile
) => Promise<string> | string
export type ImageUploadCustomCallbackFunction = (
data: UniApp.UploadFileSuccessCallbackResult
) => Promise<string> | string
export type ImageBeforeUploadFunction = (
file: ImageUploadFile
) => boolean | Promise<boolean>
export type ImageBeforeRemoveFunction = (
file: ImageUploadListItem
) => boolean | Promise<boolean>
export const imageUploadProps = buildProps({
/**
* @description 已上传的图片列表绑定值,传递的是图片的url地址
*/
modelValue: {
type: definePropType<Array<string>>(Array),
default: () => [],
},
/**
* @description 禁止上传
*/
disabled: Boolean,
/**
* @description 图片上传地址
*/
action: String,
/**
* @description 图片上传的字段名称
*/
name: {
type: String,
default: 'file',
},
/**
* @description 图片上传的header, header 中不能设置 Referer
*/
header: {
type: Object,
default: () => ({}),
},
/**
* @description 图片上传HTTP 请求中其他额外的 form data
*/
formData: {
type: Object,
default: () => ({}),
},
/**
* @description 最大允许上传个数
*/
limit: {
type: Number,
default: 9,
},
/**
* @description 自动上传
*/
autoUpload: {
type: Boolean,
default: true,
},
/**
* @description 显示删除按钮
*/
showRemove: {
type: Boolean,
default: true,
},
/**
* @description 显示错误提示信息
*/
showErrorTips: {
type: Boolean,
default: true,
},
/**
* @description 显示上传进度条
*/
showUploadProgress: {
type: Boolean,
default: true,
},
/**
* @description 上传图片的SizeType
*/
sizeType: {
type: definePropType<Array<ImageUploadSizeType>>(Array),
default: () => ['original', 'compressed'],
},
/**
* @description 上传图片的来源
*/
sourceType: {
type: definePropType<Array<ImageUploadSource>>(Array),
default: () => ['album', 'camera'],
},
/**
* @description 允许多选图片
*/
multiple: {
type: Boolean,
default: true,
},
/**
* @description 允许上传的最大图片大小,单位为byte
*/
maxSize: {
type: Number,
default: 10 * 1024 * 1024,
},
/**
* @description 允许上传的图片类型
*/
extensions: {
type: definePropType<Array<string>>(Array),
default: () => imageExtensions,
},
/**
* @description 自动移除上传失败的图片
*/
autoRemoveFaildFile: {
type: Boolean,
default: false,
},
/**
* @description 自定义上传函数
*/
customUploadHandler: {
type: definePropType<ImageUploadCustomFunction>(Function),
},
/**
* @description 自定义上传回调处理函数
*/
customUploadCallback: {
type: definePropType<ImageUploadCustomCallbackFunction>(Function),
},
/**
* @description 上传前的钩子函数
*/
beforeUpload: {
type: definePropType<ImageBeforeUploadFunction>(Function),
},
/**
* @description 删除前的钩子函数
*/
beforeRemove: {
type: definePropType<ImageBeforeRemoveFunction>(Function),
},
/**
* @description 值发生修改时是否触发表单验证
*/
validateEvent: {
type: Boolean,
default: true,
},
})
/* eslint-disable @typescript-eslint/no-unused-vars */
export const imageUploadEmits = {
[UPDATE_MODEL_EVENT]: (value: Array<string>) => isArray(value),
[CHANGE_EVENT]: (value: Array<string>) => isArray(value),
/**
* @description 图片超过最大尺寸或者文件不支持时触发
*/
oversizeOrNoSupport: (file: Array<ImageUploadFile>) => true,
/**
* @description 图片上传成功回调
*/
success: (file: ImageUploadListItem) => true,
/**
* @description 图片上传失败回调
*/
fail: (error: Error, file: ImageUploadListItem) => true,
/**
* @description 图片删除成功回调
*/
remove: (url: string) => true,
/**
* @description 图片预览回调
*/
preview: (url: string) => true,
}
/* eslint-enable @typescript-eslint/no-unused-vars */
export type ImageUploadProps = ExtractPropTypes<typeof imageUploadProps>
export type ImageUploadEmits = typeof imageUploadEmits
export type ImageUploadSizeType = (typeof imageUploadSizeTypes)[number]
export type ImageUploadSource = (typeof imageUploadSources)[number]
export type ImageExtension = (typeof imageExtensions)[number]
@@ -0,0 +1,131 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import { useNamespace } from '../../../hooks'
import { imageUploadEmits, imageUploadProps } from './image-upload'
import { useImageUpload } from './composables'
const props = defineProps(imageUploadProps)
defineEmits(imageUploadEmits)
const ns = useNamespace('image-upload')
const nsItem = useNamespace('image-upload-item')
const {
fileList,
isExceedMaxCount,
chooseFile,
retryUploadFile,
retryAllUpload,
customUploadHandle,
removeFileEvent,
clearAllFile,
previewImage,
} = useImageUpload(props)
defineExpose({
/**
* @description 手动选择文件
*/
chooseFile,
/**
* @description 手动上传图片
*/
upload: customUploadHandle,
/**
* @description 重新上传失败的文件
*/
retry: retryAllUpload,
/**
* @description 清空所有文件
*/
clear: clearAllFile,
})
</script>
<template>
<view :class="[ns.b()]">
<!-- 上传列表 -->
<view
v-for="(item, index) in fileList"
:key="index"
:class="[nsItem.b(), nsItem.is('custom', !!$slots.uploadImage)]"
>
<slot name="uploadImage" :data="item">
<!-- 已上传图片 -->
<view
class="tn-gray-light_bg"
:class="[
nsItem.e('image'),
nsItem.is('finish', item.status === 'done'),
]"
@tap.stop="previewImage(index)"
>
<image class="image" :src="item.url" mode="aspectFill" />
</view>
<!-- 删除按钮 -->
<view
v-if="showRemove && !disabled"
:class="[nsItem.e('remove')]"
@tap.stop="removeFileEvent(index)"
>
<view :class="[nsItem.em('remove', 'icon')]">
<TnIcon name="close-fill" />
</view>
</view>
<!-- 重试蒙层 -->
<view
v-if="item.status === 'failed' && !disabled"
:class="[nsItem.e('retry')]"
@tap.stop="retryUploadFile(index)"
>
<TnIcon name="refresh-simple" />
</view>
<!-- 进度波浪 -->
<!-- 进度0 ~ 100 => -300 ~ -400 -->
<view
v-if="
showUploadProgress &&
item.progress > 0 &&
item.progress < 100 &&
!disabled
"
:class="[
nsItem.e('progress'),
nsItem.is('finish', item.progress === 100),
]"
>
<view
:class="[nsItem.em('progress', 'wave')]"
:style="{ top: `${-300 - item.progress}%` }"
/>
<view
:class="[nsItem.em('progress', 'wave')]"
:style="{ top: `${-300 - item.progress}%` }"
/>
</view>
</slot>
</view>
<!-- 上传按钮 -->
<template v-if="!isExceedMaxCount && !disabled">
<slot name="uploadBtn">
<view
class="tn-gray-light_bg tn-gray-disabled_text"
:class="[nsItem.b()]"
@tap.stop="chooseFile"
>
<view :class="[nsItem.e('add-btn')]">
<slot name="addBtn">
<view :class="[nsItem.em('add-btn', 'icon')]">
<TnIcon name="add-fill" />
</view>
</slot>
</view>
</view>
</slot>
</template>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/image-upload.scss';
</style>
@@ -0,0 +1,3 @@
import type ImageUpload from './image-upload.vue'
export type TnImageUploadInstance = InstanceType<typeof ImageUpload>
@@ -0,0 +1,10 @@
export type ImageUploadList = Array<ImageUploadListItem>
export interface ImageUploadListItem {
url: string
status: 'ready' | 'uploading' | 'done' | 'failed'
progress: number
uploadTask?: UniApp.UploadTask
file?: ImageUploadFile
}
export type ImageUploadFile = UniApp.ChooseImageSuccessCallbackResultFile | File