UNPKG

@blocklet/ui-react

Version:

Some useful front-end web components that can be used in Blocklets.

272 lines (251 loc) 7.99 kB
/** * AvatarUploader - 组织头像上传组件 * * 用于创建和编辑组织时上传头像 * 支持图片裁剪、旋转等编辑功能 * 上传成功后返回文件路径供表单使用 */ import { lazy, Suspense, useRef, useMemo, useState } from 'react'; import noop from 'lodash/noop'; import PropTypes from 'prop-types'; import { Box, CircularProgress } from '@mui/material'; import { styled } from '@arcblock/ux/lib/Theme'; import { joinURL } from 'ufo'; import { useMemoizedFn } from 'ahooks'; import CameraAltIcon from '@mui/icons-material/CameraAlt'; import { translate } from '@arcblock/ux/lib/Locale/util'; import Img from '@arcblock/ux/lib/Img'; import translations from './locales'; // eslint-disable-next-line import/no-unresolved const Uploader = lazy(() => import('@blocklet/uploader').then((res) => ({ default: res.Uploader }))); // 配置常量 const UPLOAD_PREFIX = '/blocklet'; const ALLOWED_FILE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.ico']; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const MIN_CROP_SIZE = 256; const ICON_SIZE_RATIO = 3; /** * 构建组织头像显示 URL * @param {string} prefix - URL 前缀 * @param {string} teamDid - 团队 DID * @param {string} orgId - 组织 ID * @param {string} avatar - 头像文件名 * @returns {string} 完整的 URL 路径 */ function buildAvatarDisplayUrl(prefix, teamDid, avatar) { return joinURL(prefix, UPLOAD_PREFIX, teamDid, 'orgs', 'avatar', avatar); } /** * 构建头像上传 API URL(不需要 orgId) * @param {string} prefix - URL 前缀 * @param {string} teamDid - 团队 DID * @returns {string} 上传 API URL */ function buildAvatarUploadUrl(prefix, teamDid) { return joinURL(prefix, UPLOAD_PREFIX, teamDid, 'orgs', 'avatar', 'upload'); } export default function AvatarUploader({ org = null, size = 80, teamDid: teamDidProp = '', prefix = '/.well-known/service', locale = 'en', headers = noop, onChange = noop, onError = noop, editable = true, }) { const t = useMemoizedFn((key, data = {}) => { return translate(translations, key, locale, 'en', data); }); const uploaderRef = useRef(null); const [uploading, setUploading] = useState(false); // 本地预览的头像路径(用于新上传的图片) const [localAvatar, setLocalAvatar] = useState(''); // 获取 teamDid:优先使用 prop,否则从 window.blocklet.did 获取 const teamDid = useMemo(() => { if (teamDidProp) return teamDidProp; // eslint-disable-next-line no-undef return typeof window !== 'undefined' ? window.blocklet?.did : ''; }, [teamDidProp]); // 构建上传 API URL(不需要 orgId) const uploadApiUrl = useMemo(() => { if (!teamDid) return null; return buildAvatarUploadUrl(prefix, teamDid); }, [prefix, teamDid]); // 是否可以上传(需要有 teamDid 且 editable 为 true) const canUpload = editable && !!uploadApiUrl; // 打开上传弹窗 const handleOpen = useMemoizedFn(() => { if (canUpload && !uploading) { uploaderRef.current?.open(); } }); // 上传完成处理 - 返回文件路径 const handleFinish = useMemoizedFn((result) => { uploaderRef.current?.close(); setUploading(false); const { avatarPath } = result.data; setLocalAvatar(result.uploadURL); onChange(avatarPath); }); // 上传开始处理 const handleUploadStart = useMemoizedFn(() => { setUploading(true); }); // 错误处理 const handleError = useMemoizedFn((error) => { setUploading(false); console.error('Avatar upload failed:', error); onError(error); }); // 构建头像显示 URL const avatarUrl = useMemo(() => { // 优先使用本地上传的头像 if (localAvatar) { return localAvatar; } const avatar = org?.avatar; if (!avatar) return null; if (org?.id) { return buildAvatarDisplayUrl(prefix, teamDid, avatar); } if (avatar.startsWith('http://') || avatar.startsWith('https://') || avatar.startsWith('/')) { return avatar; } return joinURL(prefix, avatar); }, [localAvatar, org?.avatar, org?.id, prefix, teamDid]); // 获取显示名称 const displayName = org?.name || ''; return ( <Root $size={size} $editable={editable} $uploading={uploading} onClick={handleOpen}> {uploadApiUrl && ( <Suspense fallback={null}> <Uploader ref={uploaderRef} locale={locale} popup onUploadFinish={handleFinish} onUploadStart={handleUploadStart} onError={handleError} plugins={['ImageEditor']} installerProps={{ disabled: true }} apiPathProps={{ uploader: uploadApiUrl, disableMediaKitPrefix: true, disableMediaKitStatus: true, }} coreProps={{ restrictions: { allowedFileExts: ALLOWED_FILE_EXTENSIONS, maxFileSize: MAX_FILE_SIZE, maxNumberOfFiles: 1, }, }} dashboardProps={{ autoOpen: 'imageEditor', }} imageEditorProps={{ actions: { revert: true, rotate: true, granularRotate: true, flip: true, zoomIn: true, zoomOut: true, cropSquare: false, cropWidescreen: false, cropWidescreenVertical: false, }, cropperOptions: { autoCrop: true, autoCropArea: 1, aspectRatio: 1, initialAspectRatio: 1, croppedCanvasOptions: { minWidth: MIN_CROP_SIZE, minHeight: MIN_CROP_SIZE, }, }, }} tusProps={{ headers, }} /> </Suspense> )} {/* 使用 Img 组件显示头像,自动处理加载错误和占位图片 */} <Img key={avatarUrl || 'no-avatar'} src={avatarUrl || ''} alt={displayName} width={size} height={size} ratio={1} size="cover" position="center" lazy={false} style={{ borderRadius: '50%', overflow: 'hidden', }} /> {editable && ( <Box className="upload-overlay"> {uploading ? ( <CircularProgress size={size / ICON_SIZE_RATIO} sx={{ color: 'white' }} /> ) : ( <> <CameraAltIcon sx={{ fontSize: size / ICON_SIZE_RATIO, color: 'white' }} /> <Box component="span" sx={{ fontSize: 12, color: 'white' }}> {t('upload')} </Box> </> )} </Box> )} </Root> ); } AvatarUploader.propTypes = { org: PropTypes.shape({ id: PropTypes.string, name: PropTypes.string, avatar: PropTypes.string, }), size: PropTypes.number, teamDid: PropTypes.string, prefix: PropTypes.string, headers: PropTypes.func, onChange: PropTypes.func, onError: PropTypes.func, editable: PropTypes.bool, locale: PropTypes.string, }; const Root = styled(Box, { shouldForwardProp: (prop) => !['$size', '$editable', '$uploading'].includes(prop), })(({ $size, $editable, $uploading }) => ({ position: 'relative', width: $size, height: $size, borderRadius: '50%', overflow: 'hidden', cursor: $editable && !$uploading ? 'pointer' : 'default', '.upload-overlay': { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', opacity: $uploading ? 1 : 0, transition: 'opacity 0.2s ease-in-out', }, '&:hover .upload-overlay': { opacity: $editable ? 1 : 0, }, }));