import React, { useState, useRef, forwardRef, ForwardRefRenderFunction, useImperativeHandle, useMemo, useContext, } from 'react'; import { Platform, View, StyleSheet } from 'react-native'; import { useTheme } from '../Theme/Theme'; import { PhoneAuths, PhoneOS, UploadFileTypes, UploadListTypes, UploadWays, MimeType, } from './enum'; import ImagePreview from './ImagePreview'; import { ImageInfo, UploaderBodyProps, UploaderRef } from './interface'; import { PreviewImageModal } from './PreviewImageModal'; import DocumentPicker, { DocumentPickerResponse, } from 'react-native-document-picker'; import { launchCamera, launchImageLibrary } from 'react-native-image-picker'; import { requestPermission, saveLeaveCacheTime } from './permission'; import { useLocale } from '../Locale/locale'; import { filePathOS, filePathOSDisplay, getAcceptDocumentTypes, getAcceptMimeTypes, getMimeType, usePropsValue, waitResult, validateFilesByAccept, validateFilesByTypes, } from './help'; import { useMeasure } from '../../utils'; import { ActionSheetOptions } from '../../core/action-sheet/interface'; import { useContentStyle } from './hooks/useContentStyle'; import UploadButton from './UploadButton'; import ShouldRender from '../ShouldRender'; import ActionSheet from '../ActionSheet'; import Space from '../Space'; import TextList from './TextList'; import { isArray, isFunction, isNumber } from 'lodash'; import Toast from '../Toast'; import { PortalContext } from '../../core/portal/portal-host'; const UploaderBody: ForwardRefRenderFunction = ( props, uploaderRef ) => { const { UploaderStyle: locale, Uploader: uploaderLocal } = useLocale(); const { defaultValue = [], value, attachmentType = 'image', maxCount = 999, renderUploaderBtn = null, disabled = false, deleteIcon, showUploadBtn = true, onPreviewFile, onChange, onActionContainerClose, uploadFileType, accept, useContainerWidth = false, containerStyle, actionSheetProps = {}, tip, tipStyle, tipIcon, contentStyle: customerContentStyle, onUpload, containerSpacing = 20, onDisabledPress, imgQuality = 1, listType = UploadListTypes.PictureCard, onDownload, onRemove: cusOnRemove, beforeUpload, doUpload, onSuccess, onError, onActionResponseError, errorTip = uploaderLocal?.error, minSize, // 上传默认最大限制 20M maxSize = 1024 * 1024 * 20, maxSizeErrorTip = uploaderLocal?.maxSizeError, minSizeErrorTip = uploaderLocal?.minSizeError, subTip, subTipStyle, status: btnStatus, selectionLimit = 1, replaceWhenUploaded, } = props; const { tag } = useContext(PortalContext); const [showImagePreview, setShowImagePreview] = useState(false); const [previewIndex, setPreviewIndex] = useState(0); const [fileList, setFileList] = usePropsValue({ defaultValue, value, onChange, }); const [ref, onLayout, layout] = useMeasure(); const token = useTheme(); const { useNative = true, beforeClose } = actionSheetProps; const beforeCloseResovleRef = useRef(); // 原生蒙层关闭问题处理 actionSheet关闭带动整个蒙层消失 // 避免用户多次点击 const isPressingRef = useRef(false); const hiddenAction = ['overlay', 'cancel']; const isTextListType = listType === UploadListTypes.Text; const needUpload = isFunction(doUpload); const status: ImageInfo['status'] = needUpload ? 'isUploading' : undefined; // 新文件默认状态 const actionSheetPropsNew: Omit = useNative ? { ...actionSheetProps, useNative: actionSheetProps.useNative ?? useNative, beforeClose: (action, item, index) => { return new Promise((resolve) => { beforeCloseResovleRef.current = resolve; const bcRes = beforeClose?.(action, item, index); if (bcRes instanceof Promise) { bcRes.then((res) => { if (hiddenAction.includes(action)) { resolve(res ?? true); } }); } else { hiddenAction.includes(action) && resolve(true); } }); }, } : actionSheetProps; const contentStyle = useContentStyle({ width: layout.width, space: token['--spacing-3'], useContainerWidth, attachmentType, containerSpacing, }); const contentStyleFinal = { ...(!isTextListType && contentStyle), ...customerContentStyle, }; const hiddenPreviewModal = () => { if (showImagePreview) { setShowImagePreview(false); setPreviewIndex(0); } }; const pressUploaderBtn = () => { if (isPressingRef.current) return; isPressingRef.current = true; const cameraType = { name: locale?.cameraText, is: [UploadFileTypes.Image], callback: () => { choose(UploadWays.Camera); }, }; const imageType = { name: locale?.photoText, is: [UploadFileTypes.Image], callback: () => { choose(UploadWays.Image); }, }; const fileType = { name: locale?.fileText, is: [ UploadFileTypes.Pdf, UploadFileTypes.Audio, UploadFileTypes.AllFile, UploadFileTypes.Csv, UploadFileTypes.PlainText, UploadFileTypes.Video, UploadFileTypes.Zip, UploadFileTypes.Xls, UploadFileTypes.Xlsx, ], callback: () => { choose(UploadWays.File); }, }; const allActions = [cameraType, imageType, fileType]; let filteredActions; if (uploadFileType && Array.isArray(uploadFileType)) { filteredActions = allActions.filter((action) => action.is.some((type) => uploadFileType.includes(type)) ); } else { filteredActions = allActions; } ActionSheet({ portalTag: tag, ...actionSheetPropsNew, actions: filteredActions, closeOnPressOverlay: true, }) .catch((e) => { onActionResponseError?.(e); }) .finally(() => { isPressingRef.current = false; }); }; /** * 上传 * @param uploadFiles */ const upload = (uploadFiles: ImageInfo[]) => { if (!isArray(uploadFiles) || uploadFiles.length < 1) return; // console.log('upload==== now=====', fileList.length); if (isFunction(doUpload)) { const uploadFilesIds: string[] = uploadFiles.map( (file) => file._fileId as string ); doUpload(uploadFiles) .then((res) => { // console.log('before onSuccess', res, fileList); uploadFiles.forEach((file) => { if (uploadFilesIds.includes(file._fileId ?? '')) { file.status = 'done'; } }); setFileList((pre) => { const newFileList = [...pre]; onSuccess?.(uploadFiles, newFileList, res); return newFileList; }); }) .catch(async (err) => { const retry = await waitResult(onError?.(uploadFiles, err) || false); if (retry === true) { // 重试上传 return upload(uploadFiles); } else if (errorTip) { // 失败有提示 Toast({ message: errorTip, position: 'middle', }); } // 去除上传失败的文件 setFileList((pre) => { return ( pre?.filter?.((file) => { return !uploadFilesIds.includes(file._fileId ?? ''); }) || [] ); }); }); } }; /** * 文件体积大小是否OK? * @param files */ const fileSizeIsOK = (files: ImageInfo[]) => { if ( isNumber(minSize) && files.some((file) => (file?.fileSize ?? 0) < minSize) ) { Toast({ message: minSizeErrorTip, position: 'middle', }); return false; } if ( isNumber(maxSize) && files.some((file) => (file?.fileSize ?? 0) > maxSize) ) { Toast({ message: maxSizeErrorTip, position: 'middle', }); return false; } return true; }; /** * 文件类型是否OK?(在系统选择器绕过类型限制时进行兜底校验) * @param files */ const fileTypeIsOK = (files: ImageInfo[]) => { let validationResult = { ok: true, allowedLabel: '' }; if (accept) { validationResult = validateFilesByAccept(files, accept); } else if ( uploadFileType && Array.isArray(uploadFileType) && !uploadFileType.includes(UploadFileTypes.AllFile) ) { validationResult = validateFilesByTypes(files, uploadFileType); } if (!validationResult.ok) { Toast({ message: uploaderLocal?.typesError?.replace?.( '{x}', validationResult.allowedLabel ) || uploaderLocal?.typesError, position: 'middle', }); } return validationResult.ok; }; /** * 新文件添加 * @param newFiles */ const doAdd = async (newFiles: ImageInfo[]) => { if (!newFiles || newFiles.length < 1) { //新文件为空直接返回 return; } // 先进行类型兜底校验 if (!fileTypeIsOK(newFiles)) return; if (!fileSizeIsOK(newFiles)) return; const allowUpload = await waitResult(beforeUpload?.(newFiles) || true); if (allowUpload === false) return; //不上传 let uploadFiles = newFiles; if (isArray(allowUpload)) { uploadFiles = allowUpload; } setFileList((prev) => { return maxCount === 1 ? [...uploadFiles] : [...prev, ...uploadFiles]; }); upload(uploadFiles); }; const mediaType = useMemo( () => [UploadFileTypes.Video, UploadFileTypes.AllFile].some((t) => uploadFileType?.includes(t) ) ? 'mixed' : 'photo', [uploadFileType] ); const acceptMimeTypes = useMemo(() => getAcceptMimeTypes(accept), [accept]); const acceptDocumentTypes = useMemo( () => getAcceptDocumentTypes(accept, Platform.OS), [accept] ); const choose = async (way: UploadWays) => { let newFiles: ImageInfo[] = []; try { if (way === UploadWays.Image) { if (Platform.OS === 'ios') { const ret = await requestPermission( PhoneAuths.PhotoLibrary, uploaderLocal, useNative ); if (!ret.result) { return; } } const res = await launchImageLibrary({ mediaType, quality: imgQuality, selectionLimit, ...(Platform.OS === PhoneOS.Android && acceptMimeTypes.length ? { restrictMimeTypes: acceptMimeTypes } : {}), }); if (!res) { console.log('ImagePicker returned empty response'); return; } if (res.didCancel) { console.log('User cancelled image picker'); } else if (res.errorCode) { console.log('ImagePicker Error: ', res.errorCode); } else { const response = res.assets; newFiles = response?.map((item) => ({ fileName: item.fileName, fileSize: item.fileSize, width: item.width, height: item.height, type: getMimeType(item.type), uri: filePathOS(item.uri as string), status, _fileId: `${Date.now()}${item.fileName}`, })) || []; } } else if (way === UploadWays.Camera) { const ret = await requestPermission( PhoneAuths.Camera, uploaderLocal, useNative ); if (!ret.result) return; await saveLeaveCacheTime(); const res = await launchCamera({ mediaType: 'photo', quality: imgQuality, }); if (!res) { console.log('ImagePicker returned empty response'); return; } if (res.didCancel) { console.log('User cancelled image picker'); } else if (res.errorCode) { console.log('ImagePicker Error: ', res.errorCode); } else { const response = res.assets?.[0]; if (!response || !response.uri) return; //适配鸿蒙空数据 const curImage: ImageInfo = { fileName: response?.fileName, fileSize: response?.fileSize, width: response?.width, height: response?.height, type: getMimeType(response?.type), uri: filePathOS(response?.uri as string), status, _fileId: `${Date.now()}${response?.fileName}`, }; if (curImage.uri) { newFiles = [curImage]; } } } else if (way === UploadWays.File) { const currResponse = (await openFile()) as ImageInfo[]; if (!currResponse?.length) return; newFiles = currResponse; } } finally { doAdd(newFiles ?? []); beforeCloseResovleRef.current?.(true); beforeCloseResovleRef.current = null; onActionContainerClose?.(); } }; const openFile = async () => { const type: any[] = []; const mimeTypeSet = MimeType[Platform.OS]; const types = { ...DocumentPicker.types, ...mimeTypeSet, }; uploadFileType?.forEach((typeKey) => { if (types[typeKey]) { type.push(types[typeKey]); } }); if (type.length < 1) { type.push(DocumentPicker.types.allFiles); } try { let res = (await DocumentPicker.pick({ allowMultiSelection: selectionLimit > 1, type: acceptDocumentTypes.length ? acceptDocumentTypes : type.flat(), })) as DocumentPickerResponse[] | DocumentPickerResponse | undefined; if (!res || (isArray(res) && res.length < 1)) return []; if (!isArray(res)) { res = [res]; } if (res.length > selectionLimit) { Toast({ message: uploaderLocal.exceedsMaxCountError.replace( '{x}', selectionLimit.toString() ), position: 'middle', }); res = res.slice(0, selectionLimit); } return res.map((item) => { const { name, size, type: fileType } = item; return { ...item, fileName: name, fileSize: size, type: getMimeType(fileType || undefined), status, uri: filePathOS(item.uri), _fileId: `${Date.now()}${name}`, }; }); } catch (err: any) { if (DocumentPicker.isCancel(err)) { // User cancelled the picker, exit any dialogs or menus and move on } else { throw err; } return []; } }; const onRemove = async (index: number) => { const file = fileList?.[index]; if (!file) return; const allowRemove = await waitResult(cusOnRemove?.(file, index) ?? true); if (allowRemove === false) return; //不允许删除 const newFileList = fileList.filter((_, i) => i !== index); setFileList(newFileList); }; const onPreviewImageList = (index: number) => { setShowImagePreview(true); setPreviewIndex(index); }; const isCredentials = attachmentType === 'credentials'; const canShowButton = (replaceWhenUploaded && maxCount === 1) || fileList?.length < maxCount; const renderBtn = () => { return ( = 3 ? token['--spacing-3'] : 0, }, tipStyle, ])} renderBtn={renderUploaderBtn} onPress={onUpload || pressUploaderBtn} tip={tip} tipStyle={tipStyle} tipIcon={tipIcon} contentStyle={contentStyleFinal} isCredentials={isCredentials} disabled={disabled || !canShowButton} onDisabledPress={() => onDisabledPress?.(value)} listType={listType} subTip={subTip} subTipStyle={subTipStyle} status={btnStatus} /> ); }; const previewList = fileList.length ? fileList.map((item: ImageInfo) => { return { url: filePathOSDisplay(item.uri), }; }) : []; useImperativeHandle(uploaderRef, () => { return { upload: () => { pressUploaderBtn(); }, choose: (way: UploadWays) => { return choose(way); }, remove: (index: number) => { return onRemove(index); }, }; }); const PictureCardListRender = ( {fileList.length > 0 && fileList.map((item: ImageInfo, index: number) => { return ( onRemove(index)} onPreviewImage={() => onPreviewImageList(index)} onPreviewFile={onPreviewFile} onReUpload={() => { onRemove(index); choose(UploadWays.Image); }} contentStyle={contentStyleFinal} /> ); })} {showUploadBtn && canShowButton && renderBtn()} ); const TextListRender = ( {renderBtn()} ); return ( {PictureCardListRender} {TextListRender} ); }; export default forwardRef(UploaderBody);