@blocklet/ui-react 3.1.44 → 3.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ /* eslint-disable @typescript-eslint/naming-convention */
2
+ import { useState } from 'react';
3
+ import PropTypes from 'prop-types';
4
+ import { useReactive, useMemoizedFn } from 'ahooks';
5
+ import noop from 'lodash/noop';
6
+ import Dialog from '@arcblock/ux/lib/Dialog';
7
+ import Spinner from '@mui/material/CircularProgress';
8
+ import DialogContentText from '@mui/material/DialogContentText';
9
+ import Typography from '@mui/material/Typography';
10
+ import TextField from '@mui/material/TextField';
11
+ import Alert from '@mui/material/Alert';
12
+ import Toast from '@arcblock/ux/lib/Toast';
13
+ import Button from '@arcblock/ux/lib/Button';
14
+ import { translate } from '@arcblock/ux/lib/Locale/util';
15
+
16
+ import { formatAxiosError } from '../../UserCenter/libs/utils';
17
+ import useOrg from './use-org';
18
+ import translations from './locales';
19
+
20
+ export default function CreateOrgDialog({ onSuccess = noop, onCancel = noop, locale = 'en' }) {
21
+ const [loading, setLoading] = useState(false);
22
+ const [error, setError] = useState('');
23
+ const { createOrg } = useOrg();
24
+ const t = useMemoizedFn((key, data = {}) => {
25
+ return translate(translations, key, locale, 'en', data);
26
+ });
27
+
28
+ const form = useReactive({
29
+ name: '',
30
+ description: '',
31
+ });
32
+
33
+ const onSubmit = async () => {
34
+ const _name = form.name.trim();
35
+ if (!_name) {
36
+ setError(t('nameEmpty'));
37
+ return;
38
+ }
39
+
40
+ if (_name.length > 25) {
41
+ setError(t('nameTooLong', { length: 25 }));
42
+ return;
43
+ }
44
+
45
+ const _description = form.description.trim();
46
+
47
+ if (_description.length > 255) {
48
+ setError(t('descriptionTooLong', { length: 25 }));
49
+ return;
50
+ }
51
+
52
+ setError('');
53
+ setLoading(true);
54
+
55
+ try {
56
+ await createOrg({ name: _name, description: _description });
57
+ onSuccess();
58
+ } catch (err) {
59
+ console.error(err);
60
+ const errMsg = formatAxiosError(err);
61
+ setError(errMsg);
62
+ Toast.error(errMsg);
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ };
67
+
68
+ const body = (
69
+ <div>
70
+ <Typography component="div" style={{ marginTop: 16 }}>
71
+ <TextField
72
+ label={t('mutate.name')}
73
+ autoComplete="off"
74
+ variant="outlined"
75
+ name="name"
76
+ data-cy="mutate-org-input-name"
77
+ fullWidth
78
+ autoFocus
79
+ value={form.name}
80
+ onChange={(e) => {
81
+ setError('');
82
+ form.name = e.target.value;
83
+ }}
84
+ disabled={loading}
85
+ />
86
+ </Typography>
87
+
88
+ <Typography component="div" style={{ marginTop: 16, marginBottom: 16 }}>
89
+ <TextField
90
+ label={t('mutate.description')}
91
+ autoComplete="off"
92
+ variant="outlined"
93
+ name="description"
94
+ data-cy="mutate-org-input-description"
95
+ fullWidth
96
+ value={form.description}
97
+ onChange={(e) => {
98
+ setError('');
99
+ form.description = e.target.value;
100
+ }}
101
+ disabled={loading}
102
+ multiline
103
+ rows={3}
104
+ />
105
+ </Typography>
106
+ </div>
107
+ );
108
+
109
+ return (
110
+ <Dialog
111
+ title={t('mutate.title', { mode: t('create') })}
112
+ fullWidth
113
+ open
114
+ onClose={onCancel}
115
+ showCloseButton={false}
116
+ actions={
117
+ <>
118
+ <Button onClick={onCancel} color="inherit">
119
+ {t('cancel')}
120
+ </Button>
121
+ <Button
122
+ data-cy="mutate-org-confirm"
123
+ onClick={onSubmit}
124
+ color="primary"
125
+ disabled={loading}
126
+ variant="contained"
127
+ autoFocus>
128
+ {loading && <Spinner size={16} />}
129
+ {t('create')}
130
+ </Button>
131
+ </>
132
+ }>
133
+ <DialogContentText component="div">{body}</DialogContentText>
134
+ {!!error && (
135
+ <Alert severity="error" style={{ width: '100%', margin: 0 }}>
136
+ {error}
137
+ </Alert>
138
+ )}
139
+ </Dialog>
140
+ );
141
+ }
142
+
143
+ CreateOrgDialog.propTypes = {
144
+ onSuccess: PropTypes.func,
145
+ onCancel: PropTypes.func,
146
+ locale: PropTypes.string,
147
+ };
@@ -0,0 +1,402 @@
1
+ /**
2
+ * orgs switch
3
+ */
4
+ import PropTypes from 'prop-types';
5
+ import { useState, useRef, useMemo, useEffect } from 'react';
6
+ import { useDebounce, useInfiniteScroll, useMemoizedFn, useRequest } from 'ahooks';
7
+ import { useNavigate } from 'react-router-dom';
8
+ import { WELLKNOWN_SERVICE_PATH_PREFIX } from '@abtnode/constant';
9
+ import {
10
+ Box,
11
+ Button,
12
+ Typography,
13
+ Menu,
14
+ MenuItem,
15
+ TextField,
16
+ Divider,
17
+ Avatar,
18
+ ListItemAvatar,
19
+ ListItemText,
20
+ InputAdornment,
21
+ } from '@mui/material';
22
+ import { KeyboardArrowDown, Search, Add, OpenInNew } from '@mui/icons-material';
23
+ import noop from 'lodash/noop';
24
+ import { translate } from '@arcblock/ux/lib/Locale/util';
25
+
26
+ import useOrg from './use-org';
27
+ import CreateOrgDialog from './create';
28
+ import translations from './locales';
29
+
30
+ const PAGE_SIZE = 20;
31
+
32
+ export default function OrgsSwitch({ session, locale = 'en' }) {
33
+ const [anchorEl, setAnchorEl] = useState(null);
34
+ const [searchText, setSearchText] = useState('');
35
+ const [visible, setVisible] = useState(false);
36
+ const buttonRef = useRef(null);
37
+ const navigate = useNavigate();
38
+
39
+ const t = useMemoizedFn((key, data = {}) => {
40
+ return translate(translations, key, locale, 'en', data);
41
+ });
42
+
43
+ const { getOrgs, getCurrentOrg } = useOrg(session);
44
+
45
+ const role = useMemo(() => {
46
+ return session?.user?.role || '';
47
+ }, [session?.user?.role]);
48
+
49
+ // 根据登录用户的 role 找到对应的 org
50
+ const { data: loggedOrg = {} } = useRequest(
51
+ () => {
52
+ return getCurrentOrg(role);
53
+ },
54
+ {
55
+ ready: !!role,
56
+ refreshDeps: [role],
57
+ onError: (error) => {
58
+ console.error('Failed to get organization role', error);
59
+ },
60
+ }
61
+ );
62
+
63
+ const currentOrg = useMemo(() => {
64
+ if (loggedOrg) {
65
+ return loggedOrg;
66
+ }
67
+ const passport = session?.user?.passports?.find((p) => p.name === role);
68
+ return {
69
+ name: passport?.title || role || '-',
70
+ };
71
+ }, [loggedOrg, role, session?.user?.passports]);
72
+
73
+ const debouncedSearchText = useDebounce(searchText, { wait: 500 });
74
+
75
+ const { data, loadMore, loadingMore, reload } = useInfiniteScroll(
76
+ async (d) => {
77
+ const page = d ? Math.ceil(d.list.length / PAGE_SIZE) + 1 : 1;
78
+ const params = { page, pageSize: PAGE_SIZE, search: searchText };
79
+ const response = await getOrgs(params);
80
+
81
+ const { orgs: resultOrgs = [], paging } = response || {};
82
+ return { list: resultOrgs, total: paging?.total || 0 };
83
+ },
84
+ {
85
+ ready: !!role,
86
+ reloadDeps: [debouncedSearchText],
87
+ isNoMore: (d) => {
88
+ if (!d?.list.length) return true;
89
+ return d.list.length >= d?.total;
90
+ },
91
+ onError: (error) => {
92
+ console.error('Failed to fetch organizations list', error);
93
+ },
94
+ }
95
+ );
96
+
97
+ const { list: allOrgs = [], total = 0 } = data || {};
98
+
99
+ const hasMore = allOrgs.length < total;
100
+
101
+ // 处理滚动事件
102
+ const handleListboxScroll = useMemoizedFn((event) => {
103
+ const listbox = event.target;
104
+ const { scrollTop, scrollHeight, clientHeight } = listbox;
105
+ // 当滚动到距离底部50px时开始加载
106
+ if (scrollHeight - scrollTop - clientHeight < 50 && !loadingMore && hasMore) {
107
+ loadMore();
108
+ }
109
+ });
110
+
111
+ const open = Boolean(anchorEl);
112
+
113
+ useEffect(() => {
114
+ if (open) {
115
+ reload();
116
+ }
117
+ }, [open, reload]);
118
+
119
+ const handleClick = (event) => {
120
+ setAnchorEl(event.currentTarget);
121
+ };
122
+
123
+ const handleClose = () => {
124
+ setAnchorEl(null);
125
+ setVisible(false);
126
+ setSearchText('');
127
+ };
128
+
129
+ const handleOrgSelect = (org) => {
130
+ session.switchPassport(noop, { orgId: org.id });
131
+ handleClose();
132
+ };
133
+
134
+ const handleCreateNew = () => {
135
+ setVisible(true);
136
+ };
137
+
138
+ const handleViewAll = () => {
139
+ navigate(`${WELLKNOWN_SERVICE_PATH_PREFIX}/user/orgs?locale=${locale}`);
140
+ handleClose();
141
+ };
142
+
143
+ // 渲染组织项目
144
+ const renderOrgItem = (org) => (
145
+ <MenuItem
146
+ key={org.id}
147
+ onClick={() => handleOrgSelect(org)}
148
+ selected={org.id === currentOrg.id}
149
+ sx={{
150
+ py: 1.5,
151
+ px: 2,
152
+ '&.Mui-selected': {
153
+ backgroundColor: 'action.selected',
154
+ '&:hover': {
155
+ backgroundColor: 'action.hover',
156
+ },
157
+ },
158
+ }}>
159
+ <ListItemAvatar sx={{ minWidth: 40 }}>
160
+ <Avatar
161
+ sx={{
162
+ width: 28,
163
+ height: 28,
164
+ fontSize: 14,
165
+ bgcolor: org.isOwner ? 'primary.main' : 'grey.400',
166
+ }}>
167
+ {org.name?.[0]}
168
+ </Avatar>
169
+ </ListItemAvatar>
170
+ <Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
171
+ <ListItemText
172
+ sx={{
173
+ '& .MuiListItemText-primary': {
174
+ mb: 0,
175
+ },
176
+ '& .MuiListItemText-secondary': {
177
+ mt: '-2px',
178
+ },
179
+ }}
180
+ primary={
181
+ <Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.2 }}>
182
+ {org.name}
183
+ </Typography>
184
+ }
185
+ secondary={
186
+ <Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.1 }}>
187
+ {org.passports?.[0]?.title}
188
+ </Typography>
189
+ }
190
+ />
191
+ {org.id === currentOrg.id && (
192
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', ml: 1 }}>
193
+ <Typography variant="caption" color="primary" sx={{ fontSize: 10 }}>
194
+
195
+ </Typography>
196
+ </Box>
197
+ )}
198
+ </Box>
199
+ </MenuItem>
200
+ );
201
+
202
+ return (
203
+ <Box>
204
+ <Button
205
+ ref={buttonRef}
206
+ onClick={handleClick}
207
+ endIcon={<KeyboardArrowDown />}
208
+ sx={{
209
+ display: 'flex',
210
+ alignItems: 'center',
211
+ gap: 1,
212
+ px: 2,
213
+ py: 1,
214
+ borderRadius: 1,
215
+ textTransform: 'none',
216
+ color: 'text.primary',
217
+ '&:hover': {
218
+ backgroundColor: 'action.hover',
219
+ },
220
+ }}
221
+ data-testid="org-switch-button">
222
+ <Avatar
223
+ sx={{
224
+ width: 24,
225
+ height: 24,
226
+ fontSize: 12,
227
+ bgcolor: 'primary.main',
228
+ }}>
229
+ {currentOrg.name?.[0]}
230
+ </Avatar>
231
+ <Typography variant="body2" sx={{ fontWeight: 500 }}>
232
+ {currentOrg.name}
233
+ </Typography>
234
+ </Button>
235
+
236
+ <Menu
237
+ anchorEl={anchorEl}
238
+ open={open}
239
+ onClose={handleClose}
240
+ anchorOrigin={{
241
+ vertical: 'bottom',
242
+ horizontal: 'left',
243
+ }}
244
+ transformOrigin={{
245
+ vertical: 'top',
246
+ horizontal: 'left',
247
+ }}
248
+ PaperProps={{
249
+ sx: {
250
+ width: 340,
251
+ maxHeight: 480,
252
+ overflow: 'visible',
253
+ mt: 0.5,
254
+ borderRadius: 2,
255
+ boxShadow: '0px 4px 20px rgba(0, 0, 0, 0.08)',
256
+ border: (theme) => `1px solid ${theme.palette.divider}`,
257
+ },
258
+ }}
259
+ MenuListProps={{
260
+ sx: { py: 0 },
261
+ }}>
262
+ {/* 搜索框 */}
263
+ <Box sx={{ p: 1.5, pb: 1 }}>
264
+ <TextField
265
+ fullWidth
266
+ size="small"
267
+ placeholder={t('search')}
268
+ value={searchText}
269
+ onChange={(e) => setSearchText(e.target.value)}
270
+ InputProps={{
271
+ startAdornment: (
272
+ <InputAdornment position="start">
273
+ <Search fontSize="small" color="action" />
274
+ </InputAdornment>
275
+ ),
276
+ }}
277
+ onClick={(e) => e.stopPropagation()}
278
+ onKeyDown={(e) => e.stopPropagation()}
279
+ sx={{
280
+ '& .MuiOutlinedInput-root': {
281
+ borderRadius: 1,
282
+ },
283
+ }}
284
+ />
285
+ </Box>
286
+
287
+ {/* 组织标题 */}
288
+ <Box sx={{ px: 2, pb: 1 }}>
289
+ <Typography
290
+ variant="subtitle2"
291
+ color="text.secondary"
292
+ sx={{
293
+ fontSize: 11,
294
+ fontWeight: 600,
295
+ textTransform: 'uppercase',
296
+ letterSpacing: 0.5,
297
+ }}>
298
+ {t('orgs')}
299
+ </Typography>
300
+ </Box>
301
+
302
+ {/* 组织列表 */}
303
+ <Box sx={{ maxHeight: 240, overflow: 'auto' }} onScroll={handleListboxScroll}>
304
+ {allOrgs.length > 0 ? (
305
+ <>
306
+ {allOrgs.map((org, index) => {
307
+ const isLast = index === allOrgs.length - 1;
308
+ return (
309
+ <Box key={org.id}>
310
+ {renderOrgItem(org)}
311
+ {/* 加载更多指示器 */}
312
+ {isLast && hasMore && (
313
+ <Box
314
+ sx={{
315
+ display: 'flex',
316
+ justifyContent: 'center',
317
+ py: 1,
318
+ color: 'text.secondary',
319
+ fontSize: '0.875rem',
320
+ }}>
321
+ {loadingMore ? t('loadingMore') : ''}
322
+ </Box>
323
+ )}
324
+ </Box>
325
+ );
326
+ })}
327
+ </>
328
+ ) : (
329
+ <Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
330
+ <Typography variant="body2" color="text.secondary">
331
+ {t('myJoinedEmpty')}
332
+ </Typography>
333
+ </Box>
334
+ )}
335
+ </Box>
336
+
337
+ <Divider />
338
+
339
+ {/* 创建新组织 */}
340
+ <MenuItem
341
+ onClick={handleCreateNew}
342
+ sx={{
343
+ py: 1.5,
344
+ px: 2,
345
+ '&:hover': {
346
+ backgroundColor: 'action.hover',
347
+ },
348
+ }}>
349
+ <ListItemAvatar sx={{ minWidth: 40 }}>
350
+ <Avatar
351
+ sx={{
352
+ width: 28,
353
+ height: 28,
354
+ bgcolor: 'success.main',
355
+ color: 'success.contrastText',
356
+ }}>
357
+ <Add fontSize="small" />
358
+ </Avatar>
359
+ </ListItemAvatar>
360
+ <ListItemText
361
+ primary={
362
+ <Typography variant="body2" sx={{ fontWeight: 500 }}>
363
+ {t('createNew')}
364
+ </Typography>
365
+ }
366
+ />
367
+ </MenuItem>
368
+
369
+ <Divider />
370
+
371
+ {/* 查看所有组织链接 */}
372
+ <Box sx={{ p: 1.5 }}>
373
+ <Button
374
+ onClick={handleViewAll}
375
+ variant="text"
376
+ size="small"
377
+ endIcon={<OpenInNew fontSize="small" />}
378
+ sx={{
379
+ color: 'primary.main',
380
+ fontWeight: 500,
381
+ p: 0,
382
+ fontSize: 14,
383
+ minWidth: 'auto',
384
+ '&:hover': {
385
+ backgroundColor: 'transparent',
386
+ textDecoration: 'underline',
387
+ },
388
+ }}>
389
+ {t('viewAll')}
390
+ </Button>
391
+ </Box>
392
+ </Menu>
393
+
394
+ {visible && <CreateOrgDialog onSuccess={handleViewAll} onCancel={() => setVisible(false)} locale={locale} />}
395
+ </Box>
396
+ );
397
+ }
398
+
399
+ OrgsSwitch.propTypes = {
400
+ session: PropTypes.object.isRequired,
401
+ locale: PropTypes.string,
402
+ };
@@ -0,0 +1,40 @@
1
+ const translations = {
2
+ en: {
3
+ search: 'Search',
4
+ orgs: 'Organizations',
5
+ loadingMore: 'Loading more',
6
+ myJoinedEmpty: 'You have not joined any organizations yet',
7
+ createNew: 'Create New Organization',
8
+ viewAll: 'View All Organizations',
9
+ nameEmpty: 'Name cannot be empty',
10
+ nameTooLong: 'Name must be less than {length} characters',
11
+ descriptionTooLong: 'Description must be less than {length} characters',
12
+ cancel: 'Cancel',
13
+ create: 'Create',
14
+ mutate: {
15
+ title: '{mode} Organization',
16
+ name: 'Name',
17
+ description: 'Description',
18
+ },
19
+ },
20
+ zh: {
21
+ search: '搜索',
22
+ orgs: '组织',
23
+ loadingMore: '加载更多',
24
+ myJoinedEmpty: '您还没有加入任何组织',
25
+ createNew: '创建新组织',
26
+ viewAll: '查看所有组织',
27
+ nameEmpty: '名称不能为空',
28
+ nameTooLong: '名称不能超过{length}个字符',
29
+ descriptionTooLong: '描述不能超过{length}个字符',
30
+ cancel: '取消',
31
+ create: '创建',
32
+ mutate: {
33
+ title: '{mode}组织',
34
+ name: '组织名称',
35
+ description: '组织描述',
36
+ },
37
+ },
38
+ };
39
+
40
+ export default translations;
@@ -0,0 +1,66 @@
1
+ import { useMemoizedFn } from 'ahooks';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+
4
+ import { client } from '../../libs/client';
5
+ import { formatAxiosError } from '../../UserCenter/libs/utils';
6
+
7
+ export default function useOrg(session) {
8
+ const getOrgs = useMemoizedFn(async ({ search, page = 1, pageSize = 20 }) => {
9
+ try {
10
+ const response = await client.user.getOrgs({ search, page, pageSize });
11
+ return response;
12
+ } catch (error) {
13
+ console.error(error);
14
+ return {
15
+ orgs: [],
16
+ paging: {
17
+ page,
18
+ pageSize,
19
+ total: 0,
20
+ },
21
+ };
22
+ }
23
+ });
24
+
25
+ const createOrg = useMemoizedFn(async ({ name, description }) => {
26
+ try {
27
+ const response = await client.user.createOrg({ name, description });
28
+ return response.data;
29
+ } catch (error) {
30
+ console.error(error);
31
+ Toast.error(formatAxiosError(error));
32
+ return null;
33
+ }
34
+ });
35
+
36
+ const getOrg = useMemoizedFn(async (orgId) => {
37
+ try {
38
+ const data = await client.user.getOrg(orgId);
39
+ return data;
40
+ } catch (error) {
41
+ session.logout();
42
+ console.error(error);
43
+ return null;
44
+ }
45
+ });
46
+
47
+ const getCurrentOrg = useMemoizedFn(async (roleName) => {
48
+ try {
49
+ const response = await client.user.getRole(roleName);
50
+ if (response.orgId) {
51
+ const org = await getOrg(response.orgId);
52
+ return org;
53
+ }
54
+ return null;
55
+ } catch (error) {
56
+ console.error(error);
57
+ return null;
58
+ }
59
+ });
60
+
61
+ return {
62
+ getOrgs,
63
+ createOrg,
64
+ getCurrentOrg,
65
+ };
66
+ }