This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
@import '../progress/index';
@import '../button/index';
.nut-theme-dark {
.nut-uploader {
&__upload {
background: $dark-background;
}
}
.nut-uploader__preview-list {
color: $dark-color;
background: $dark-background2;
}
.close {
color: $dark-color !important;
}
}
.nut-uploader {
position: relative;
display: flex;
flex-wrap: wrap;
&__slot {
position: relative;
}
&__upload {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: $uploader-picture-width;
height: $uploader-picture-height;
background: $uploader-background;
}
&__input {
position: absolute !important;
top: 0;
left: 0;
width: 100% !important;
height: 100% !important;
overflow: hidden;
cursor: pointer !important;
opacity: 0;
&:disabled,
&.disabled {
cursor: not-allowed !important;
}
}
&__preview {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
margin-bottom: 10px;
box-shadow: 0 2px 10px 0 rgb(0 0 0 / 10%);
&__progress {
position: absolute;
top: 0;
left: 0;
/* #ifdef H5 */
z-index: 99;
/* #endif */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: rgb(0 0 0 / 60%);
&__msg {
margin-top: 6px;
font-size: 12px;
color: $white;
}
}
&.list {
width: 100%;
margin-top: 10px;
margin-right: 0;
margin-bottom: 0;
}
&-list {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 32px;
.nut-uploader__preview-img__file__name {
display: flex;
align-items: center;
height: 100%;
padding: 2px 4px;
.file-name__tips {
padding: 0 20px;
margin-left: 4px;
@include oneline-ellipsis;
}
}
.nut-uploader__preview-img__file__del {
position: absolute;
top: 6px;
right: 6px;
}
.nut-uploader__preview-img__file__link {
position: absolute;
top: 8px;
left: 6px;
}
.nut-progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
.nut-progress-outer {
height: 2px !important;
}
}
}
&-img {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: $uploader-picture-width;
height: $uploader-picture-height;
border-radius: 6px;
.close {
position: absolute;
top: 0;
right: 0;
z-index: 1;
color: rgb(0 0 0 / 60%);
transform: translate(50%, -50%);
}
.tips {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
height: 0;
font-size: 12px;
color: $white;
text-align: center;
background: rgb(0 0 0 / 54%);
transition: height 0.3s;
@include oneline-ellipsis;
}
&__c {
max-width: 100%;
max-height: 100%;
border-radius: 6px;
}
&__file {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
transition: all 0.3s;
&__name {
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
height: 100%;
padding: 10px;
overflow: hidden;
font-size: 12px;
color: $text-color;
&.error {
color: red !important;
}
&.success {
color: #1890ff !important;
}
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './type'
export * from './uploader'
export * from './use-uploader'

View File

@@ -0,0 +1,25 @@
export type SizeType = 'original' | 'compressed'
export type SourceType = 'album' | 'camera'
export type AcceptType = 'image' | 'media' | 'video' | 'all'
export type MediaType = 'image' | 'video' | 'mix'
export type FileType = 'image' | 'video' | 'audio'
export type FileItemStatus = 'ready' | 'uploading' | 'success' | 'error'
export interface FileItem {
status: FileItemStatus
message: string
uid?: string
name: string
url?: string
type?: FileType
path?: string
percentage?: string | number
formData?: any
}

View File

@@ -0,0 +1,169 @@
import type { ExtractPropTypes, PropType } from 'vue'
import type { Interceptor } from '../_utils'
import {
commonProps,
makeArrayProp,
makeNumericProp,
makeStringProp,
nullableBooleanProp,
truthProp,
} from '../_utils'
import type { AcceptType, FileItem, MediaType, SizeType, SourceType } from './type'
import type { UploadOptions } from './use-uploader'
export const uploaderProps = {
...commonProps,
/**
* @description 发到后台的文件参数名
* - 类型为 `string`
* - 默认值为 `'file'`
*/
name: makeStringProp('file'),
/**
* @description 上传服务器的接口地址
*/
url: String,
/**
* @description 选择文件的来源
* - 类型为 `Array<SourceType>`
* - 默认值为 `['album', 'camera']`
*/
sourceType: makeArrayProp<SourceType>(['album', 'camera']),
/**
* @description 选择文件类型
* - 类型为 `Array<MediaType>`
* - 默认值为 `['image', 'video', 'mix']`
*/
mediaType: makeArrayProp<MediaType>(['image', 'video', 'mix']),
/**
* @description 超时时间,单位为毫秒
* - 类型为 `number | string`
* - 默认值为 `1000 * 30`
*/
timeout: makeNumericProp(1000 * 30),
/**
* @description 默认已经上传的文件列表
*/
fileList: makeArrayProp<any>([]),
/**
* @description 是否上传成功后展示预览图
* - 类型为 `boolean`
* - 默认值为 `true`
*/
isPreview: truthProp,
/**
* @description 上传列表的内建样式,支持两种基础样式 `picture`、`list`
* - 类型为 `string`
* - 默认值为 `'picture'`
*/
listType: makeStringProp<'picture' | 'list'>('picture'),
/**
* @description 是否展示删除按钮
*/
isDeletable: truthProp,
/**
* @description 上传请求的 http method
* - 类型为 `string`
* - 默认值为 `'post'`
*/
method: makeStringProp('post'),
capture: Boolean,
/**
* @description 可以设定最大上传文件的大小(字节)
*/
maximize: makeNumericProp(Number.MAX_VALUE),
/**
* @description 最多可以选择的文件个数微信基础库2.25.0前最多可支持9个文件2.25.0及以后最多可支持20个文件
*/
maximum: makeNumericProp(9),
accept: makeStringProp<AcceptType>('image'),
/**
* @description 设置上传的请求头部
*/
headers: { type: Object, default: {} },
/**
* @description 附加上传的信息 formData
*/
data: { type: Object, default: {} },
/**
* @description 接口响应的成功状态status
*/
xhrState: makeNumericProp(200),
/**
* @description 是否支持文件多选
*/
multiple: truthProp,
/**
* @description 是否禁用文件上传
*/
disabled: nullableBooleanProp,
/**
* @description 是否在选取文件后立即进行上传false 时需要手动执行ref的`submit`方法进行上传
*/
autoUpload: truthProp,
/**
* @description 执行 `uni.uploadFile` 上传时,自定义方法
*/
beforeUpload: {
type: Function,
default: null,
},
/**
* @description 除文件时的回调,返回值为 false 时不移除。支持返回一个 Promise 对象Promise 对象 resolve(false) 或 reject 时不移除
*/
beforeDelete: {
type: Function as PropType<Interceptor>,
default: null,
},
/**
* @description 当accept为video时生效是否压缩视频默认为true默认 true
* - compressed: { type: Boolean, default: true },
* - 当accept为video时生效拍摄视频最长拍摄时间单位秒默认 60
*/
maxDuration: makeNumericProp(60),
/**
* @description 所选的图片的尺寸, 可选值为original compressed
*/
sizeType: makeArrayProp<SizeType>(['compressed', 'original']),
/**
* 当accept为video时生效可选值为back或front
*/
camera: makeStringProp<'back' | 'front' | undefined>('back'),
/**
* @description 预览图片的 mode 属性
*/
mode: makeStringProp<'aspectFit' | 'scaleToFill' | 'aspectFill' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'center' | 'left' | 'right' | 'top left' | 'top right' | 'bottom left' | 'bottom right'>('aspectFit'),
}
export type UploaderProps = ExtractPropTypes<typeof uploaderProps>
export const uploaderEmits = {
'start': (option: UploadOptions) => option instanceof Object,
'progress': (val: { event: any, option: UploadOptions, percentage: string | number }) => val instanceof Object,
'update:fileList': (val: FileItem[]) => val instanceof Object,
'oversize': (val: any[]) => val instanceof Object,
'success': (val: any) => val instanceof Object,
'failure': (err: any) => err instanceof Object,
'change': (val: { fileList: FileItem[], event?: any }) => val instanceof Object,
'delete': (val: { file: FileItem, fileList: FileItem[], index: number }) => val instanceof Object,
'fileItemClick': (val: any) => val instanceof Object,
}
export type UploaderEmits = typeof uploaderEmits
export interface UploaderInst {
/**
* @description 手动上传模式,执行上传操作
*/
submit: () => void
/**
* @description 调用选择文件的方法,效果等同于点击 nut-uploader 组件
*/
chooseImage: () => void
/**
* @description 清空已选择的文件队列(该方法一般配合在手动模式上传时使用)
* @param index
* @returns
*/
clearUploadQueue: (index: number) => void
}

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, defineComponent, reactive, ref, toRef, watch } from 'vue'
import { PREFIX } from '../_constants'
import { getMainClass } from '../_utils'
import { useTranslate } from '../../locale'
import NutButton from '../button/button.vue'
import { useFormDisabled } from '../form/form'
import NutIcon from '../icon/icon.vue'
import NutProgress from '../progress/progress.vue'
import type { FileItem } from './type'
import { uploaderEmits, uploaderProps } from './uploader'
import type { ChooseFile, OnProgressUpdateResult, UploadFileSuccessCallbackResult, UploadOptions } from './use-uploader'
import { chooseFile, createUploader } from './use-uploader'
const props = defineProps(uploaderProps)
const emit = defineEmits(uploaderEmits)
defineExpose({ submit, chooseImage, clearUploadQueue })
const fileList = ref(props.fileList as Array<FileItem>)
const uploadQueue = ref<Promise<any>[]>([])
const disabled = useFormDisabled(toRef(props, 'disabled'))
watch(
() => props.fileList,
() => {
fileList.value = props.fileList
},
)
function fileItemClick(fileItem: FileItem) {
emit('fileItemClick', { fileItem })
}
function executeUpload(fileItem: FileItem, index: number) {
const { type, url, formData } = fileItem
const uploadOption: UploadOptions = {
url: props.url ? props.url : '',
filePath: url,
name: props.name,
fileType: type,
header: props.headers,
timeout: +props?.timeout,
xhrState: +props.xhrState,
formData,
file: fileItem as any,
}
uploadOption.onStart = (option: UploadOptions) => {
fileItem.status = 'ready'
fileItem.message = translate('readyUpload')
clearUploadQueue(index)
emit('start', option)
}
uploadOption.onProgress = (event: OnProgressUpdateResult, option: UploadOptions) => {
fileItem.status = 'uploading'
fileItem.message = translate('uploading')
fileItem.percentage = event?.progress
emit('progress', { event, option, percentage: fileItem.percentage })
}
uploadOption.onSuccess = (data: UploadFileSuccessCallbackResult, option: UploadOptions) => {
fileItem.status = 'success'
fileItem.message = translate('success')
emit('success', {
data,
responseText: data,
option,
fileItem,
})
emit('update:fileList', fileList.value)
}
uploadOption.onFailure = (data, option: UploadOptions) => {
fileItem.status = 'error'
fileItem.message = translate('error')
emit('failure', {
data,
responseText: data,
option,
fileItem,
})
}
const task = createUploader(uploadOption)
if (props.beforeUpload) {
props.beforeUpload(uni.uploadFile, uploadOption)
}
else if (props.autoUpload) {
task.upload()
}
else {
uploadQueue.value.push(
new Promise((resolve, reject) => {
resolve(task)
}),
)
}
}
function clearUploadQueue(index = -1) {
if (index > -1) {
uploadQueue.value.splice(index, 1)
}
else {
uploadQueue.value = []
fileList.value.splice(0, fileList.value.length)
}
}
function submit() {
Promise.all(uploadQueue.value).then((res) => {
res.forEach(i => i.upload())
})
}
function readFile(files: ChooseFile[]) {
files.forEach((file, index: number) => {
let fileType = file.type
const filepath = (file.tempFilePath || file.path || file.url) as string
const fileItem = reactive({} as FileItem)
if (file.fileType) {
fileType = file.fileType
}
else {
const imgReg = /\.(png|jpeg|jpg|webp|gif)$/i
if (!fileType && (imgReg.test(filepath) || filepath.includes('data:image')))
fileType = 'image'
}
fileItem.uid = new Date().getTime().toString() + Math.random().toString(36).substring(2, 9)
fileItem.path = filepath
fileItem.name = file.name || filepath
fileItem.status = 'ready'
fileItem.message = translate('waitingUpload')
fileItem.type = fileType!
fileItem.formData = props.data
if (props.isPreview)
fileItem.url = fileType === 'video' ? file.url : filepath
fileList.value.push(fileItem)
executeUpload(fileItem, index)
})
}
function filterFiles(files: ChooseFile[]) {
const maximum = (props.maximum as number) * 1
const maximize = (props.maximize as number) * 1
const oversizes = new Array<ChooseFile>()
files = files.filter((file: ChooseFile) => {
if (file.size > maximize) {
oversizes.push(file)
return false
}
return true
})
if (oversizes.length)
emit('oversize', oversizes)
const currentFileLength = files.length + fileList.value.length
if (currentFileLength > maximum)
files.splice(files.length - (currentFileLength - maximum))
return files
}
async function onDelete(file: FileItem, index: number) {
clearUploadQueue(index)
if (props.beforeDelete == null || await props.beforeDelete(file, fileList)) {
fileList.value.splice(index, 1)
emit('delete', {
file,
fileList: fileList.value,
index,
})
}
else {
// console.log('用户阻止了删除!');
}
}
function chooseImage(event: InputEvent) {
if (disabled.value)
return
const maximum = (props.maximum as number) * 1
chooseFile({
accept: props.accept,
multiple: props.multiple,
capture: props.capture,
maxDuration: +props.maxDuration,
sizeType: props.sizeType,
camera: props.camera,
maxCount: maximum - fileList.value.length,
}, props, fileList.value).then((files) => {
const filteredFiles: ChooseFile[] = filterFiles(
new Array<ChooseFile>().slice.call(files),
)
readFile(filteredFiles)
emit('change', { fileList: fileList.value, event })
})
}
const classes = computed(() => {
return getMainClass(props, componentName)
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-uploader`
const { translate } = useTranslate(componentName)
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="customStyle">
<view v-if="$slots.default" class="nut-uploader__slot">
<slot />
<template v-if="Number(maximum) - fileList.length">
<NutButton custom-class="nut-uploader__input" @click="(chooseImage as any)" />
</template>
</view>
<view
v-for="(item, index) in fileList"
:key="item.uid"
class="nut-uploader__preview"
:class="[listType]"
>
<view v-if="listType === 'picture' && !$slots.default" class="nut-uploader__preview-img">
<view v-if="item.status !== 'success'" class="nut-uploader__preview__progress">
<template v-if="item.status !== 'ready'">
<NutIcon v-if="item.status === 'error'" name="failure" custom-color="#fff" />
<NutIcon v-else name="loading" custom-color="#fff" />
</template>
<view class="nut-uploader__preview__progress__msg">
{{ item.message }}
</view>
</view>
<view v-if="isDeletable && !disabled" class="close" @click="onDelete(item, index)">
<slot name="deleteIcon">
<NutIcon name="failure" />
</slot>
</view>
<image
v-if="(item?.type?.includes('image') || item?.type?.includes('video')) && item.url"
class="nut-uploader__preview-img__c"
:mode="mode"
:src="item.url"
@click="fileItemClick(item)"
/>
<view v-else class="nut-uploader__preview-img__file">
<view class="nut-uploader__preview-img__file__name" @click="fileItemClick(item)">
<view class="file-name__tips">
{{ item.name }}
</view>
</view>
</view>
<view class="tips">
{{ item.name }}
</view>
</view>
<view v-else-if="listType === 'list'" class="nut-uploader__preview-list">
<view class="nut-uploader__preview-img__file__name" :class="[item.status]" @click="fileItemClick(item)">
<NutIcon name="link" custom-class="nut-uploader__preview-img__file__link" />
<view class="file-name__tips">
{{ item.name }}
</view>
<NutIcon
v-if="isDeletable && !disabled"
name="del"
custom-color="#808080"
custom-class="nut-uploader__preview-img__file__del"
@click="onDelete(item, index)"
/>
</view>
<NutProgress
v-if="item.status === 'uploading'"
size="small"
:percentage="item.percentage"
stroke-color="linear-gradient(270deg, rgba(18,126,255,1) 0%,rgba(32,147,255,1) 32.815625%,rgba(13,242,204,1) 100%)"
:show-text="false"
/>
</view>
</view>
<view
v-if="listType === 'picture' && !$slots.default && Number(maximum) - fileList.length"
class="nut-uploader__upload"
:class="[listType]"
>
<slot name="uploadIcon">
<NutIcon name="photograph" custom-color="#808080" />
</slot>
<NutButton custom-class="nut-uploader__input" :class="{ disabled }" @click="(chooseImage as any)" />
</view>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,240 @@
import { omit } from '../_utils'
import type { AcceptType, FileType, SizeType } from './type'
import type { UploaderProps } from './uploader'
interface UniChooseFileSuccessCallbackResult {
/**
* 本地文件路径列表
*/
tempFilePaths?: string[]
/**
* 本地文件列表,每一项是一个 File 对象
*/
tempFiles: ({
path: string
size: number
name: string
type: string
} & File)[]
}
interface UniChooseImageSuccessCallbackResult extends UniChooseFileSuccessCallbackResult {
}
interface UniChooseVideoSuccessCallbackResult {
/**
* 本地文件路径
*/
tempFilePath?: string
/**
* 本地文件,一个 File 对象
*/
tempFile: ({
path: string
size: number
name: string
type: string
} & File)
/**
* 选定视频的时间长度单位为s
*/
duration: number
/**
* 选定视频的数据量大小
*/
size: number
/**
* 返回选定视频的高
*/
height: number
/**
* 返回选定视频的宽
*/
width: number
/**
* 包含扩展名的文件名称
*/
name: string
}
export interface ChooseFile {
size: number
type?: FileType
fileType?: FileType
originalFileObj?: any
tempFilePath?: string
thumbTempFilePath?: string
path?: string
name: string
thumb?: string
url?: string
[k: string]: unknown
}
function omitProps<T>(obj: T, keys: string[]) {
if (!['[object Object]', '[object File]'].includes(Object.prototype.toString.call(obj)))
return {}
return omit(obj as unknown as Record<string, unknown>, keys)
}
function formatImage(res: UniChooseImageSuccessCallbackResult): ChooseFile[] {
return res.tempFiles.map(item => ({
...omitProps(item, ['path']),
type: 'image',
url: item.path,
thumb: item.path,
size: item.size,
name: item.name || 'image',
}))
}
function formatVideo(res: UniChooseVideoSuccessCallbackResult): ChooseFile[] {
return [{
...omitProps(res.tempFile, ['path']),
type: 'video',
url: res.tempFilePath,
thumb: res.tempFilePath,
size: res.tempFile.size,
name: res.tempFile.name || 'video',
}]
}
function formatMedia(res: UniApp.ChooseMediaSuccessCallbackResult & { name?: string }): ChooseFile[] {
return res.tempFiles.map(item => ({
...omitProps(item, ['fileType', 'thumbTempFilePath', 'tempFilePath']),
type: res.type as FileType,
url: item.tempFilePath,
thumb: res.type === 'video' ? item.thumbTempFilePath : item.tempFilePath,
size: item.size,
name: res?.name || 'media',
}))
}
export interface ChooseFileOptions {
accept: AcceptType
multiple: boolean
capture: boolean
maxDuration: number
sizeType: SizeType[]
camera?: 'back' | 'front'
maxCount: number
}
export function chooseFile({
accept,
multiple,
maxDuration,
sizeType,
camera,
}: ChooseFileOptions, props: UploaderProps, fileList: any[]): Promise<ChooseFile[] | ChooseFile> {
return new Promise((resolve, reject) => {
// chooseMedia 目前只支持微信小程序原生,其余端全部使用 chooseImage API
// #ifdef MP-WEIXIN
uni.chooseMedia({
/** 最多可以选择的文件个数 */
count: multiple ? Number(props.maximum) * 1 - fileList.length : 1,
/** 文件类型 */
mediaType: props.mediaType,
/** 图片和视频选择的来源 */
sourceType: props.sourceType,
/** 拍摄视频最长拍摄时间,单位秒。时间范围为 3s 至 30s 之间 */
maxDuration,
/** 仅对 mediaType 为 image 时有效,是否压缩所选文件 */
sizeType,
/** 仅在 sourceType 为 camera 时生效,使用前置或后置摄像头 */
camera,
/** 接口调用失败的回调函数 */
fail: reject,
/** 接口调用成功的回调函数 */
success: res => resolve(formatMedia(res)),
})
// #endif
// #ifndef MP-WEIXIN
if (accept === 'image') {
uni.chooseImage({
// 选择数量
count: props.multiple ? (props.maximum as number) * 1 - props.fileList.length : 1,
// 可以指定是原图还是压缩图,默认二者都有
sizeType,
sourceType: props.sourceType,
success: (res) => {
resolve(formatImage(res as UniChooseFileSuccessCallbackResult))
},
fail: reject,
})
}
else if (accept === 'video') {
uni.chooseVideo({
sourceType: props.sourceType,
success: (res) => {
resolve(formatVideo(res as UniChooseVideoSuccessCallbackResult))
},
fail: reject,
})
}
else if (accept === 'all') {
uni.chooseFile({
type: 'all',
// 选择数量
count: props.multiple ? (props.maximum as number) * 1 - props.fileList.length : 1,
// 可以指定是原图还是压缩图,默认二者都有
sizeType,
sourceType: props.sourceType,
success: (res) => {
resolve(formatImage(res as UniChooseFileSuccessCallbackResult))
},
fail: reject,
})
}
// #endif
})
}
export type OnProgressUpdateResult = UniApp.OnProgressUpdateResult
export type UploadFileSuccessCallbackResult = UniApp.UploadFileSuccessCallbackResult
export type GeneralCallbackResult = UniApp.GeneralCallbackResult
export type UploadOptions = UniNamespace.UploadFileOption & {
xhrState?: number
onStart?: (option: UploadOptions) => void
onProgress?: (result: UniApp.OnProgressUpdateResult, option: UploadOptions) => void
onSuccess?: (result: UniApp.UploadFileSuccessCallbackResult, option: UploadOptions) => void
onFailure?: (result: UniApp.GeneralCallbackResult | UniApp.UploadFileSuccessCallbackResult, option: UploadOptions) => void
}
export function createUploader(options: UploadOptions) {
const upload = () => {
const uploadTask = uni.uploadFile({
url: options.url,
fileType: options.fileType,
file: options.file,
filePath: options.filePath,
name: options.name,
header: options.header,
timeout: options.timeout,
formData: options.formData,
success: (result) => {
if (options.xhrState === result.statusCode)
options.onSuccess?.(result, options)
else
options.onFailure?.(result, options)
},
fail: (result) => {
options.onFailure?.(result, options)
},
})
options.onStart?.(options)
uploadTask.onProgressUpdate((event) => {
options.onProgress?.(event, options)
})
}
return { upload }
}