@blocklet/ui-react
Version:
Some useful front-end web components that can be used in Blocklets.
272 lines (251 loc) • 7.99 kB
JSX
/**
* 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,
},
}));