UNPKG

@blocklet/ui-react

Version:

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

408 lines (375 loc) 11.1 kB
/** * 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, };