@blocklet/ui-react
Version:
Some useful front-end web components that can be used in Blocklets.
408 lines (375 loc) • 11.1 kB
JSX
/**
* orgs switch
*/
import PropTypes from 'prop-types';
import { useState, useRef, useMemo, useEffect } from 'react';
import { useDebounce, useInfiniteScroll, useMemoizedFn, useRequest } from 'ahooks';
import { WELLKNOWN_SERVICE_PATH_PREFIX } from '@abtnode/constant';
import {
Box,
Button,
Typography,
Menu,
MenuItem,
TextField,
Divider,
Avatar,
ListItemAvatar,
ListItemText,
InputAdornment,
} from '@mui/material';
import { joinURL, withQuery } from 'ufo';
import { KeyboardArrowDown, Search, Add, OpenInNew } from '@mui/icons-material';
import { translate } from '@arcblock/ux/lib/Locale/util';
import useOrg from './use-org';
import { CreateOrgDialog } from './create';
import translations from './locales';
const PAGE_SIZE = 20;
export default function OrgsSwitch({ session, locale = 'en' }) {
const [anchorEl, setAnchorEl] = useState(null);
const [searchText, setSearchText] = useState('');
const [visible, setVisible] = useState(false);
const buttonRef = useRef(null);
const t = useMemoizedFn((key, data = {}) => {
return translate(translations, key, locale, 'en', data);
});
const { getOrgs, getCurrentOrg } = useOrg(session);
const role = useMemo(() => {
return session?.user?.role || '';
}, [session?.user?.role]);
// 根据登录用户的 role 找到对应的 org
const { data: loggedOrg = {} } = useRequest(
() => {
return getCurrentOrg(role);
},
{
ready: !!role,
refreshDeps: [role],
onError: (error) => {
console.error('Failed to get organization role', error);
},
}
);
const currentOrg = useMemo(() => {
if (loggedOrg) {
return loggedOrg;
}
const passport = session?.user?.passports?.find((p) => p.name === role);
return {
name: passport?.title || role || '-',
};
}, [loggedOrg, role, session?.user?.passports]);
const debouncedSearchText = useDebounce(searchText, { wait: 500 });
const { data, loadMore, loadingMore, reload } = useInfiniteScroll(
async (d) => {
const page = d ? Math.ceil(d.list.length / PAGE_SIZE) + 1 : 1;
const params = { page, pageSize: PAGE_SIZE, search: searchText };
const response = await getOrgs(params);
const { orgs: resultOrgs = [], paging } = response || {};
return { list: resultOrgs, total: paging?.total || 0 };
},
{
ready: !!role,
reloadDeps: [debouncedSearchText],
isNoMore: (d) => {
if (!d?.list.length) return true;
return d.list.length >= d?.total;
},
onError: (error) => {
console.error('Failed to fetch organizations list', error);
},
}
);
const { list: allOrgs = [], total = 0 } = data || {};
const hasMore = allOrgs.length < total;
// 处理滚动事件
const handleListboxScroll = useMemoizedFn((event) => {
const listbox = event.target;
const { scrollTop, scrollHeight, clientHeight } = listbox;
// 当滚动到距离底部50px时开始加载
if (scrollHeight - scrollTop - clientHeight < 50 && !loadingMore && hasMore) {
loadMore();
}
});
const open = Boolean(anchorEl);
useEffect(() => {
if (open) {
reload();
}
}, [open, reload]);
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
setVisible(false);
setSearchText('');
};
const handleOrgSelect = (org) => {
session.switchPassport(
() => {
window.location.reload();
},
{ orgId: org.id }
);
handleClose();
};
const handleCreateNew = () => {
setVisible(true);
};
const handleViewAll = () => {
const targetUrl = withQuery(joinURL(WELLKNOWN_SERVICE_PATH_PREFIX, 'user/orgs'), { locale });
window.location.href = targetUrl;
handleClose();
};
// 渲染组织项目
const renderOrgItem = (org) => (
<MenuItem
key={org.id}
onClick={() => handleOrgSelect(org)}
selected={org.id === currentOrg.id}
sx={{
py: 1.5,
px: 2,
'&.Mui-selected': {
backgroundColor: 'action.selected',
'&:hover': {
backgroundColor: 'action.hover',
},
},
}}>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar
sx={{
width: 28,
height: 28,
fontSize: 14,
bgcolor: org.isOwner ? 'primary.main' : 'grey.400',
}}>
{org.name?.[0]}
</Avatar>
</ListItemAvatar>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
sx={{
'& .MuiListItemText-primary': {
mb: 0,
},
'& .MuiListItemText-secondary': {
mt: '-2px',
},
}}
primary={
<Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.2 }}>
{org.name}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.1 }}>
{org.passports?.[0]?.title}
</Typography>
}
/>
{org.id === currentOrg.id && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', ml: 1 }}>
<Typography variant="caption" color="primary" sx={{ fontSize: 10 }}>
✓
</Typography>
</Box>
)}
</Box>
</MenuItem>
);
return (
<Box>
<Button
ref={buttonRef}
onClick={handleClick}
endIcon={<KeyboardArrowDown />}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderRadius: 1,
textTransform: 'none',
color: 'text.primary',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
data-testid="org-switch-button">
<Avatar
sx={{
width: 24,
height: 24,
fontSize: 12,
bgcolor: 'primary.main',
}}>
{currentOrg.name?.[0]}
</Avatar>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{currentOrg.name}
</Typography>
</Button>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
PaperProps={{
sx: {
width: 340,
maxHeight: 480,
overflow: 'visible',
mt: 0.5,
borderRadius: 2,
boxShadow: '0px 4px 20px rgba(0, 0, 0, 0.08)',
border: (theme) => `1px solid ${theme.palette.divider}`,
},
}}
MenuListProps={{
sx: { py: 0 },
}}>
{/* 搜索框 */}
<Box sx={{ p: 1.5, pb: 1 }}>
<TextField
fullWidth
size="small"
placeholder={t('search')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search fontSize="small" color="action" />
</InputAdornment>
),
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 1,
},
}}
/>
</Box>
{/* 组织标题 */}
<Box sx={{ px: 2, pb: 1 }}>
<Typography
variant="subtitle2"
color="text.secondary"
sx={{
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}>
{t('orgs')}
</Typography>
</Box>
{/* 组织列表 */}
<Box sx={{ maxHeight: 240, overflow: 'auto' }} onScroll={handleListboxScroll}>
{allOrgs.length > 0 ? (
<>
{allOrgs.map((org, index) => {
const isLast = index === allOrgs.length - 1;
return (
<Box key={org.id}>
{renderOrgItem(org)}
{/* 加载更多指示器 */}
{isLast && hasMore && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 1,
color: 'text.secondary',
fontSize: '0.875rem',
}}>
{loadingMore ? t('loadingMore') : ''}
</Box>
)}
</Box>
);
})}
</>
) : (
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('myJoinedEmpty')}
</Typography>
</Box>
)}
</Box>
<Divider />
{/* 创建新组织 */}
<MenuItem
onClick={handleCreateNew}
sx={{
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'action.hover',
},
}}>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar
sx={{
width: 28,
height: 28,
bgcolor: 'success.main',
color: 'success.contrastText',
}}>
<Add fontSize="small" />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{t('createNew')}
</Typography>
}
/>
</MenuItem>
<Divider />
{/* 查看所有组织链接 */}
<Box sx={{ p: 1.5 }}>
<Button
onClick={handleViewAll}
variant="text"
component="a"
size="small"
endIcon={<OpenInNew fontSize="small" />}
sx={{
color: 'primary.main',
fontWeight: 500,
p: 0,
fontSize: 14,
minWidth: 'auto',
'&:hover': {
backgroundColor: 'transparent',
textDecoration: 'underline',
},
}}>
{t('viewAll')}
</Button>
</Box>
</Menu>
{visible && <CreateOrgDialog onSuccess={handleViewAll} onCancel={() => setVisible(false)} locale={locale} />}
</Box>
);
}
OrgsSwitch.propTypes = {
session: PropTypes.object.isRequired,
locale: PropTypes.string,
};