@arcblock/ux 2.13.13 → 2.13.14
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.
- package/lib/Address/responsive-did-address.js +3 -1
- package/lib/DIDConnect/app-icon.d.ts +8 -0
- package/lib/DIDConnect/app-icon.js +31 -0
- package/lib/DIDConnect/app-info-item.d.ts +7 -0
- package/lib/DIDConnect/app-info-item.js +73 -0
- package/lib/DIDConnect/did-connect-footer.d.ts +4 -0
- package/lib/DIDConnect/did-connect-footer.js +54 -0
- package/lib/DIDConnect/did-connect-logo.d.ts +1 -0
- package/lib/DIDConnect/did-connect-logo.js +11 -0
- package/lib/DIDConnect/index.d.ts +7 -0
- package/lib/DIDConnect/index.js +7 -0
- package/lib/DIDConnect/powered-by.d.ts +3 -0
- package/lib/DIDConnect/powered-by.js +46 -0
- package/lib/DIDConnect/with-container.d.ts +11 -0
- package/lib/DIDConnect/with-container.js +273 -0
- package/lib/DIDConnect/with-ux-theme.d.ts +1 -0
- package/lib/DIDConnect/with-ux-theme.js +23 -0
- package/lib/Dialog/confirm.d.ts +6 -1
- package/lib/Dialog/confirm.js +7 -3
- package/lib/Dialog/use-confirm.js +6 -0
- package/lib/Locale/util.d.ts +3 -3
- package/lib/Locale/util.js +6 -1
- package/lib/LoginButton/index.d.ts +12 -0
- package/lib/LoginButton/index.js +74 -0
- package/lib/SessionUser/components/un-login.js +42 -31
- package/lib/SharedBridge/index.d.ts +16 -0
- package/lib/SharedBridge/index.js +109 -0
- package/lib/SharedBridge/need-storage-access-api-dialog.d.ts +7 -0
- package/lib/SharedBridge/need-storage-access-api-dialog.js +212 -0
- package/lib/Theme/index.d.ts +2 -2
- package/lib/Theme/index.js +1 -1
- package/lib/Util/iframe.d.ts +5 -0
- package/lib/Util/iframe.js +24 -0
- package/lib/Util/index.d.ts +10 -1
- package/lib/Util/index.js +67 -4
- package/package.json +7 -6
- package/src/Address/responsive-did-address.tsx +11 -1
- package/src/DIDConnect/app-icon.tsx +36 -0
- package/src/DIDConnect/app-info-item.tsx +82 -0
- package/src/DIDConnect/did-connect-footer.tsx +51 -0
- package/src/DIDConnect/did-connect-logo.tsx +8 -0
- package/src/DIDConnect/index.ts +7 -0
- package/src/DIDConnect/powered-by.tsx +48 -0
- package/src/DIDConnect/with-container.tsx +307 -0
- package/src/DIDConnect/with-ux-theme.tsx +22 -0
- package/src/Dialog/confirm.jsx +31 -23
- package/src/Dialog/use-confirm.jsx +6 -0
- package/src/Locale/util.ts +7 -2
- package/src/LoginButton/index.tsx +73 -0
- package/src/SessionUser/components/un-login.tsx +34 -27
- package/src/SharedBridge/index.tsx +123 -0
- package/src/SharedBridge/need-storage-access-api-dialog.tsx +171 -0
- package/src/Theme/index.ts +2 -2
- package/src/Util/iframe.ts +19 -0
- package/src/Util/index.ts +77 -4
@@ -0,0 +1,123 @@
|
|
1
|
+
import { Box } from '@mui/material';
|
2
|
+
import type { SxProps } from '@mui/material';
|
3
|
+
import React, { memo, useEffect, useId, useRef } from 'react';
|
4
|
+
import { withQuery } from 'ufo';
|
5
|
+
import { useMemoizedFn, useReactive } from 'ahooks';
|
6
|
+
|
7
|
+
import { mergeSx } from '../Util/style';
|
8
|
+
import { callIframe, getCallbackAction } from '../Util/iframe';
|
9
|
+
import { Locale } from '../type';
|
10
|
+
import NeedStorageAccessApiDialog from './need-storage-access-api-dialog';
|
11
|
+
import { withContainer, withUxTheme } from '../DIDConnect';
|
12
|
+
|
13
|
+
const SharedBridge = memo(function SharedBridge({
|
14
|
+
src,
|
15
|
+
onClick,
|
16
|
+
onLoad,
|
17
|
+
sx,
|
18
|
+
iframeRef,
|
19
|
+
locale = 'en',
|
20
|
+
...rest
|
21
|
+
}: {
|
22
|
+
src: string;
|
23
|
+
onClick: (data: { action: string; value: boolean; visitorId?: string }) => void;
|
24
|
+
onLoad: () => void;
|
25
|
+
sx?: SxProps;
|
26
|
+
iframeRef?: React.RefObject<HTMLIFrameElement>;
|
27
|
+
locale?: Locale;
|
28
|
+
}) {
|
29
|
+
const _iframeRef = useRef<HTMLIFrameElement>(null);
|
30
|
+
const refId = useId();
|
31
|
+
const dataId = `shared-bridge_${refId}`;
|
32
|
+
const currentState = useReactive({
|
33
|
+
open: false,
|
34
|
+
hasStorageAccess: false,
|
35
|
+
get origin() {
|
36
|
+
try {
|
37
|
+
return new URL(src).origin;
|
38
|
+
} catch (error) {
|
39
|
+
return src;
|
40
|
+
}
|
41
|
+
},
|
42
|
+
get host() {
|
43
|
+
try {
|
44
|
+
return new URL(src).host;
|
45
|
+
} catch (error) {
|
46
|
+
return src;
|
47
|
+
}
|
48
|
+
},
|
49
|
+
});
|
50
|
+
|
51
|
+
const targetIframeRef = iframeRef ?? _iframeRef;
|
52
|
+
|
53
|
+
useEffect(() => {
|
54
|
+
async function handleMessage(event: MessageEvent) {
|
55
|
+
const { data } = event;
|
56
|
+
if (data.action === getCallbackAction(dataId, 'requestStorageAccess')) {
|
57
|
+
currentState.open = false;
|
58
|
+
|
59
|
+
if (!data.value) {
|
60
|
+
onClick(data);
|
61
|
+
return;
|
62
|
+
}
|
63
|
+
|
64
|
+
const { value: visitorId } = await callIframe(targetIframeRef.current as HTMLIFrameElement, 'getVisitorId');
|
65
|
+
onClick({ ...data, visitorId });
|
66
|
+
} else if (data.action === getCallbackAction(dataId, 'preRequestStorageAccess')) {
|
67
|
+
currentState.open = true;
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
window.addEventListener('message', handleMessage);
|
72
|
+
return () => {
|
73
|
+
window.removeEventListener('message', handleMessage);
|
74
|
+
};
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
76
|
+
}, [onClick, dataId, targetIframeRef?.current]);
|
77
|
+
|
78
|
+
const DialogComponent = withUxTheme(withContainer(NeedStorageAccessApiDialog));
|
79
|
+
|
80
|
+
const handleLoad = useMemoizedFn(() => {
|
81
|
+
callIframe(targetIframeRef.current as HTMLIFrameElement, 'hasStorageAccess').then(({ value }) => {
|
82
|
+
currentState.hasStorageAccess = value;
|
83
|
+
});
|
84
|
+
onLoad();
|
85
|
+
});
|
86
|
+
|
87
|
+
return currentState.hasStorageAccess ? null : (
|
88
|
+
<>
|
89
|
+
<DialogComponent
|
90
|
+
popup
|
91
|
+
locale={locale}
|
92
|
+
blocklet={window.blocklet}
|
93
|
+
open={currentState.open}
|
94
|
+
origin={currentState.origin}
|
95
|
+
host={currentState.host}
|
96
|
+
onClose={() => {}}
|
97
|
+
/>
|
98
|
+
<Box
|
99
|
+
{...rest}
|
100
|
+
component="iframe"
|
101
|
+
ref={targetIframeRef}
|
102
|
+
onLoad={handleLoad}
|
103
|
+
title="shared-bridge"
|
104
|
+
data-id={dataId}
|
105
|
+
src={withQuery(src, { id: dataId })}
|
106
|
+
sx={mergeSx(
|
107
|
+
{
|
108
|
+
border: 0,
|
109
|
+
position: 'absolute',
|
110
|
+
top: 0,
|
111
|
+
left: 0,
|
112
|
+
width: '100%',
|
113
|
+
height: '100%',
|
114
|
+
cursor: 'pointer',
|
115
|
+
},
|
116
|
+
sx
|
117
|
+
)}
|
118
|
+
/>
|
119
|
+
</>
|
120
|
+
);
|
121
|
+
});
|
122
|
+
|
123
|
+
export default SharedBridge;
|
@@ -0,0 +1,171 @@
|
|
1
|
+
import { Box, Typography, Chip, List, ListItem } from '@mui/material';
|
2
|
+
import { Icon } from '@iconify/react';
|
3
|
+
import externalLinkIcon from '@iconify-icons/tabler/external-link';
|
4
|
+
import lockOutlineIcon from '@iconify-icons/material-symbols/lock-outline';
|
5
|
+
import checkCircleIcon from '@iconify-icons/material-symbols/check-circle';
|
6
|
+
import rocketLaunchRoundedIcon from '@iconify-icons/material-symbols/rocket-launch-rounded';
|
7
|
+
import { useCreation, useMemoizedFn } from 'ahooks';
|
8
|
+
import { useEffect } from 'react';
|
9
|
+
import { getDIDMotifInfo } from '@arcblock/did-motif';
|
10
|
+
|
11
|
+
import { Locale } from '../type';
|
12
|
+
import { translate } from '../Locale/util';
|
13
|
+
import { getDIDColor, isEthereumDid } from '../Util';
|
14
|
+
import { DIDConnectFooter } from '../DIDConnect';
|
15
|
+
|
16
|
+
const translations: Record<
|
17
|
+
Locale,
|
18
|
+
{
|
19
|
+
allow: string;
|
20
|
+
dataUsage: string;
|
21
|
+
title: string;
|
22
|
+
clickAllow: ({ allowButton }: { allowButton: React.ReactNode }) => React.ReactNode;
|
23
|
+
reason: ({ site }: { site: React.ReactNode }) => React.ReactNode;
|
24
|
+
afterAllow: {
|
25
|
+
title: string;
|
26
|
+
list1: string;
|
27
|
+
list2: string;
|
28
|
+
};
|
29
|
+
}
|
30
|
+
> = {
|
31
|
+
en: {
|
32
|
+
allow: 'Allow',
|
33
|
+
dataUsage:
|
34
|
+
'Your data is only used for identity authentication, and will not be collected or used for any other purpose.',
|
35
|
+
title: 'Cross-site authorization request',
|
36
|
+
clickAllow: ({ allowButton }: { allowButton: React.ReactNode }) => {
|
37
|
+
return <>You only need to click the {allowButton} button above, and you will not see this request again.</>;
|
38
|
+
},
|
39
|
+
reason: ({ site }) => {
|
40
|
+
return <>For a better login experience, we need to apply for the storage permission of the {site} site.</>;
|
41
|
+
},
|
42
|
+
afterAllow: {
|
43
|
+
title: 'After authorization, you will enjoy:',
|
44
|
+
list1: 'More convenient login experience',
|
45
|
+
list2: 'Faster access speed',
|
46
|
+
},
|
47
|
+
},
|
48
|
+
zh: {
|
49
|
+
allow: '允许',
|
50
|
+
dataUsage: '您的数据仅用于身份认证,不会被收集或用于其他用途。',
|
51
|
+
title: '跨站授权请求',
|
52
|
+
clickAllow: ({ allowButton }) => {
|
53
|
+
return <>您只需要点击屏幕上方的 {allowButton} 按钮,后续将不会再看到这个请求。</>;
|
54
|
+
},
|
55
|
+
reason: ({ site }) => {
|
56
|
+
return <>为了让您获得更好的登录体验,我们需要申请 {site} 站点存储权限。</>;
|
57
|
+
},
|
58
|
+
afterAllow: {
|
59
|
+
title: '授权后,您将享受:',
|
60
|
+
list1: '更便捷的登录体验',
|
61
|
+
list2: '更快的访问速度',
|
62
|
+
},
|
63
|
+
},
|
64
|
+
};
|
65
|
+
|
66
|
+
export default function NeedStorageAccessApiDialog({
|
67
|
+
locale = 'en',
|
68
|
+
origin,
|
69
|
+
host,
|
70
|
+
setColor,
|
71
|
+
}: {
|
72
|
+
locale?: Locale;
|
73
|
+
origin: string;
|
74
|
+
host: string;
|
75
|
+
setColor: (color: string) => void;
|
76
|
+
}) {
|
77
|
+
const t = useMemoizedFn((key, data = {}) => {
|
78
|
+
return translate(translations, key, locale, 'en', data);
|
79
|
+
});
|
80
|
+
const currentAppColor = useCreation(() => {
|
81
|
+
const did = window.blocklet.appPid;
|
82
|
+
const isEthDid = isEthereumDid(did);
|
83
|
+
const didMotifInfo = isEthDid ? undefined : getDIDMotifInfo(did);
|
84
|
+
if (isEthDid) {
|
85
|
+
return getDIDColor(did);
|
86
|
+
}
|
87
|
+
|
88
|
+
return didMotifInfo.color;
|
89
|
+
}, []);
|
90
|
+
|
91
|
+
useEffect(() => {
|
92
|
+
setColor(currentAppColor);
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
94
|
+
}, [currentAppColor]);
|
95
|
+
|
96
|
+
return (
|
97
|
+
<Box
|
98
|
+
sx={{
|
99
|
+
backgroundColor: 'background.default',
|
100
|
+
display: 'flex',
|
101
|
+
flexDirection: 'column',
|
102
|
+
height: '100%',
|
103
|
+
position: 'relative',
|
104
|
+
maxWidth: '100%',
|
105
|
+
transition: 'width 0.2s ease-in-out',
|
106
|
+
margin: 'auto',
|
107
|
+
p: 3,
|
108
|
+
pb: 0,
|
109
|
+
gap: 2,
|
110
|
+
}}>
|
111
|
+
<Typography
|
112
|
+
component="h1"
|
113
|
+
variant="h4"
|
114
|
+
sx={{
|
115
|
+
fontWeight: 700,
|
116
|
+
fontFamily: 'Lexend',
|
117
|
+
display: 'flex',
|
118
|
+
alignItems: 'center',
|
119
|
+
gap: 1,
|
120
|
+
}}>
|
121
|
+
<Box component={Icon} icon={lockOutlineIcon} fontSize={28} sx={{ color: 'warning.main' }} />
|
122
|
+
{t('title')}
|
123
|
+
</Typography>
|
124
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
125
|
+
{/* 不需要随意更改以下内容的格式化,否则会影响到 UI 的展示 */}
|
126
|
+
<Typography>
|
127
|
+
{t('reason', {
|
128
|
+
site: (
|
129
|
+
<Chip
|
130
|
+
clickable
|
131
|
+
component="a"
|
132
|
+
href={origin}
|
133
|
+
label={host}
|
134
|
+
size="small"
|
135
|
+
deleteIcon={<Icon icon={externalLinkIcon} />}
|
136
|
+
onDelete={() => {}}
|
137
|
+
target="_blank"
|
138
|
+
/>
|
139
|
+
),
|
140
|
+
})}
|
141
|
+
</Typography>
|
142
|
+
<Typography>
|
143
|
+
{t('clickAllow', {
|
144
|
+
allowButton: <Chip label={t('allow')} size="small" color="success" />,
|
145
|
+
})}
|
146
|
+
</Typography>
|
147
|
+
<Box sx={{ mt: 2 }}>
|
148
|
+
<Typography sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
149
|
+
<Box component={Icon} icon={checkCircleIcon} fontSize={24} sx={{ color: 'success.main' }} />
|
150
|
+
{t('afterAllow.title')}
|
151
|
+
</Typography>
|
152
|
+
<List dense sx={{ py: 0, pl: 2 }}>
|
153
|
+
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
|
154
|
+
<Box component={Icon} icon={rocketLaunchRoundedIcon} fontSize={20} sx={{ color: 'success.main' }} />
|
155
|
+
{t('afterAllow.list1')}
|
156
|
+
</ListItem>
|
157
|
+
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
|
158
|
+
<Box component={Icon} icon={rocketLaunchRoundedIcon} fontSize={20} sx={{ color: 'success.main' }} />
|
159
|
+
{t('afterAllow.list2')}
|
160
|
+
</ListItem>
|
161
|
+
</List>
|
162
|
+
</Box>
|
163
|
+
|
164
|
+
<Typography component="div" variant="body2" color="grey.700">
|
165
|
+
{t('dataUsage')}
|
166
|
+
</Typography>
|
167
|
+
</Box>
|
168
|
+
<DIDConnectFooter currentAppColor={currentAppColor} />
|
169
|
+
</Box>
|
170
|
+
);
|
171
|
+
}
|
package/src/Theme/index.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
import { CreateMUIStyled, Theme } from '@mui/material';
|
2
|
-
import { styled as muiStyled, useTheme } from '@mui/material
|
1
|
+
import type { CreateMUIStyled, Theme } from '@mui/material';
|
2
|
+
import { styled as muiStyled, useTheme } from '@mui/material';
|
3
3
|
|
4
4
|
export * from './theme';
|
5
5
|
export { default as ThemeProvider } from './theme-provider';
|
@@ -0,0 +1,19 @@
|
|
1
|
+
export function getCallbackAction(id: string, action: string) {
|
2
|
+
return `callback_${action}_${id}`;
|
3
|
+
}
|
4
|
+
|
5
|
+
// eslint-disable-next-line require-await
|
6
|
+
export async function callIframe(iframe: HTMLIFrameElement, action: string) {
|
7
|
+
const callbackAction = getCallbackAction(iframe.dataset.id as string, action);
|
8
|
+
const promise = new Promise<{ value: any; action: string }>((resolve) => {
|
9
|
+
const handleMessage = ({ data }: MessageEvent) => {
|
10
|
+
if (data.action === callbackAction) {
|
11
|
+
window.removeEventListener('message', handleMessage);
|
12
|
+
resolve(data);
|
13
|
+
}
|
14
|
+
};
|
15
|
+
window.addEventListener('message', handleMessage);
|
16
|
+
});
|
17
|
+
iframe?.contentWindow?.postMessage({ action, callback: callbackAction }, '*');
|
18
|
+
return promise;
|
19
|
+
}
|
package/src/Util/index.ts
CHANGED
@@ -5,9 +5,12 @@ import { getDIDMotifInfo, colors } from '@arcblock/did-motif';
|
|
5
5
|
import isNil from 'lodash/isNil';
|
6
6
|
import omitBy from 'lodash/omitBy';
|
7
7
|
import pRetry from 'p-retry';
|
8
|
+
import Cookies from 'js-cookie';
|
9
|
+
import colorConvert from 'color-convert';
|
8
10
|
import deepmerge, { type DeepmergeOptions } from '@mui/utils/deepmerge';
|
9
11
|
import { DID_PREFIX, BLOCKLET_SERVICE_PATH_PREFIX } from './constant';
|
10
12
|
import type { $TSFixMe, Locale } from '../type';
|
13
|
+
import { getFederatedEnabled } from './federated';
|
11
14
|
|
12
15
|
let dateTool: $TSFixMe | null = null;
|
13
16
|
const IP_V4_REGEX = /^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/;
|
@@ -442,16 +445,74 @@ export const isUrl = (str: string) => {
|
|
442
445
|
return /^https?:\/\//.test(str);
|
443
446
|
};
|
444
447
|
|
445
|
-
const visitorIdKey = '
|
448
|
+
const visitorIdKey = 'vid';
|
449
|
+
const visitorIdKeyLegacy = '__visitor_id';
|
450
|
+
|
446
451
|
export const getVisitorId = () => {
|
447
|
-
|
452
|
+
// FIXME: @zhanghan 短期内做一个兼容,确保在 migrate 前的请求能够携带正确的 vid
|
453
|
+
return Cookies.get(visitorIdKey) || localStorage.getItem(visitorIdKeyLegacy);
|
448
454
|
};
|
449
455
|
|
450
456
|
export const setVisitorId = (value: string | null) => {
|
451
457
|
if (value === null) {
|
452
|
-
|
458
|
+
Cookies.remove(visitorIdKey, {
|
459
|
+
sameSite: 'None',
|
460
|
+
secure: true,
|
461
|
+
});
|
453
462
|
} else {
|
454
|
-
|
463
|
+
Cookies.set(visitorIdKey, value, {
|
464
|
+
sameSite: 'None',
|
465
|
+
secure: true,
|
466
|
+
expires: 365,
|
467
|
+
});
|
468
|
+
}
|
469
|
+
};
|
470
|
+
|
471
|
+
export const ensureVisitorId = () => {
|
472
|
+
let visitorId = localStorage.getItem(visitorIdKeyLegacy);
|
473
|
+
if (visitorId) {
|
474
|
+
localStorage.removeItem(visitorIdKeyLegacy);
|
475
|
+
setVisitorId(visitorId);
|
476
|
+
}
|
477
|
+
if (getVisitorId()) {
|
478
|
+
return;
|
479
|
+
}
|
480
|
+
|
481
|
+
if (!getFederatedEnabled()) {
|
482
|
+
try {
|
483
|
+
// 在支持 crypto.randomUUID 的环境中使用
|
484
|
+
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
485
|
+
visitorId = window.crypto.randomUUID();
|
486
|
+
} else {
|
487
|
+
// 在不支持 crypto.randomUUID 的环境中生成随机 ID
|
488
|
+
const randomValues = new Uint8Array(16);
|
489
|
+
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
|
490
|
+
window.crypto.getRandomValues(randomValues);
|
491
|
+
} else {
|
492
|
+
// 降级方案:使用 Math.random 生成
|
493
|
+
for (let i = 0; i < 16; i++) {
|
494
|
+
randomValues[i] = Math.floor(Math.random() * 256);
|
495
|
+
}
|
496
|
+
}
|
497
|
+
|
498
|
+
// 转换为 UUID 格式
|
499
|
+
const hexArray = Array.from(randomValues).map((b) => b.toString(16).padStart(2, '0'));
|
500
|
+
visitorId = [
|
501
|
+
hexArray.slice(0, 4).join(''),
|
502
|
+
hexArray.slice(4, 6).join(''),
|
503
|
+
hexArray.slice(6, 8).join(''),
|
504
|
+
hexArray.slice(8, 10).join(''),
|
505
|
+
hexArray.slice(10, 16).join(''),
|
506
|
+
].join('-');
|
507
|
+
}
|
508
|
+
} catch (error) {
|
509
|
+
// 如果上述方法都失败,使用时间戳和随机数生成
|
510
|
+
visitorId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
511
|
+
}
|
512
|
+
}
|
513
|
+
|
514
|
+
if (visitorId) {
|
515
|
+
setVisitorId(visitorId);
|
455
516
|
}
|
456
517
|
};
|
457
518
|
|
@@ -540,6 +601,18 @@ export const cleanedObj = (obj: object) => {
|
|
540
601
|
return omitBy(obj, isNil);
|
541
602
|
};
|
542
603
|
|
604
|
+
/**
|
605
|
+
* 将十六进制颜色转换为 RGBA
|
606
|
+
* @param hex 十六进制颜色字符串 (例如: "#FF0000" 或 "FF0000")
|
607
|
+
* @param alpha 透明度值 (0-1 之间,默认为 1)
|
608
|
+
* @returns RGBA 颜色字符串 (例如: "rgba(255, 0, 0, 1)")
|
609
|
+
*/
|
610
|
+
export function hexToRgba(hex: string, alpha = 1) {
|
611
|
+
const [r, g, b] = colorConvert.hex.rgb(hex);
|
612
|
+
// 返回 RGBA 格式
|
613
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
614
|
+
}
|
615
|
+
|
543
616
|
/**
|
544
617
|
* 依次对数组中的对象进行深度合并
|
545
618
|
* @param objects - 需要合并的对象数组
|