@pnt-team/pnt-component-frontend 0.0.91 → 0.0.95
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/README.md +149 -54
- package/lib/cjs/api/cos.d.ts +103 -0
- package/lib/cjs/api/cos.js +137 -0
- package/lib/cjs/components/PntCosUploader/index.d.ts +135 -0
- package/lib/cjs/components/PntCosUploader/index.js +209 -0
- package/lib/cjs/components/PntImage/index.d.ts +9 -3
- package/lib/cjs/components/PntImage/index.js +9 -1
- package/lib/cjs/components/PntSecureBox/index.js +8 -5
- package/lib/cjs/components/PntUpload/index.d.ts +149 -0
- package/lib/cjs/components/PntUpload/index.js +228 -0
- package/lib/cjs/components/PntUpload/index.scss +101 -0
- package/lib/cjs/hooks/useHighSecurity.d.ts +55 -15
- package/lib/cjs/hooks/useHighSecurity.js +168 -35
- package/lib/cjs/index.d.ts +6 -0
- package/lib/cjs/index.js +18 -2
- package/lib/esm/api/cos.d.ts +103 -0
- package/lib/esm/api/cos.js +189 -0
- package/lib/esm/components/PntCosUploader/index.d.ts +135 -0
- package/lib/esm/components/PntCosUploader/index.js +289 -0
- package/lib/esm/components/PntImage/index.d.ts +9 -3
- package/lib/esm/components/PntImage/index.js +22 -3
- package/lib/esm/components/PntSecureBox/index.js +9 -3
- package/lib/esm/components/PntUpload/index.d.ts +149 -0
- package/lib/esm/components/PntUpload/index.js +276 -0
- package/lib/esm/components/PntUpload/index.scss +101 -0
- package/lib/esm/hooks/useHighSecurity.d.ts +55 -15
- package/lib/esm/hooks/useHighSecurity.js +174 -32
- package/lib/esm/index.d.ts +6 -0
- package/lib/esm/index.js +9 -1
- package/lib/umd/pnt-component-frontend.min.css +1 -1
- package/lib/umd/pnt-component-frontend.min.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import usePntCosUploader from "../PntCosUploader";
|
|
2
|
+
import PntImage from "../PntImage";
|
|
3
|
+
import React, { useMemo, useState } from 'react';
|
|
4
|
+
import { DeleteIcon, FileIcon, UploadIcon } from 'tdesign-icons-react';
|
|
5
|
+
import { Progress, Upload } from 'tdesign-react';
|
|
6
|
+
import "./index.scss";
|
|
7
|
+
|
|
8
|
+
// ==========================================
|
|
9
|
+
// 类型定义
|
|
10
|
+
// ==========================================
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PntUpload 文案集合
|
|
14
|
+
* @description 所有内部展示文案均可通过 texts prop 自定义,用于多语言场景
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 校验/上传失败阶段
|
|
19
|
+
*/
|
|
20
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
21
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
22
|
+
// ==========================================
|
|
23
|
+
// 常量与工具
|
|
24
|
+
// ==========================================
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TEXTS = {
|
|
27
|
+
uploading: '上传中',
|
|
28
|
+
uploadFailed: '上传失败,请重试',
|
|
29
|
+
sizeInvalid: '文件大小超出限制',
|
|
30
|
+
typeInvalid: '文件类型不支持',
|
|
31
|
+
selectFile: '选择文件',
|
|
32
|
+
reselect: '重新选择'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 将 accept 归一化为小写 token 列表
|
|
37
|
+
*/
|
|
38
|
+
const toAcceptList = accept => {
|
|
39
|
+
const raw = Array.isArray(accept) ? accept : accept ? accept.split(',') : [];
|
|
40
|
+
return raw.map(item => item.trim().toLowerCase()).filter(Boolean);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 校验文件类型是否匹配 accept
|
|
45
|
+
*/
|
|
46
|
+
const matchAccept = (file, acceptList) => {
|
|
47
|
+
if (!acceptList.length) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const name = file.name.toLowerCase();
|
|
51
|
+
const type = file.type.toLowerCase();
|
|
52
|
+
return acceptList.some(token => {
|
|
53
|
+
if (token.startsWith('.')) {
|
|
54
|
+
return name.endsWith(token);
|
|
55
|
+
}
|
|
56
|
+
if (token.endsWith('/*')) {
|
|
57
|
+
return !!type && type.startsWith(token.slice(0, -1));
|
|
58
|
+
}
|
|
59
|
+
return type === token;
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** 从 URL 中提取文件名(用于编辑态回显) */
|
|
64
|
+
const getFileNameFromUrl = url => {
|
|
65
|
+
if (!url) {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
const lastSeg = url.split('?')[0].split('/').pop() || '';
|
|
69
|
+
try {
|
|
70
|
+
return decodeURIComponent(lastSeg);
|
|
71
|
+
} catch {
|
|
72
|
+
return lastSeg;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ==========================================
|
|
77
|
+
// PntUpload 组件
|
|
78
|
+
// ==========================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* PntUpload 业务上传组件
|
|
82
|
+
* @description 基于 TDesign Upload + PntImage + usePntCosUploader,
|
|
83
|
+
* 内置 COS 凭证获取、直传、进度、校验、错误处理;支持图片预览 / 文件两种模式
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```tsx
|
|
87
|
+
* <PntUpload
|
|
88
|
+
* scene="default"
|
|
89
|
+
* pathKey="marketing_icon"
|
|
90
|
+
* context={{ gameId }}
|
|
91
|
+
* accept="image/png"
|
|
92
|
+
* sizeLimit={500 * 1024}
|
|
93
|
+
* imagePreview
|
|
94
|
+
* value={iconUrl}
|
|
95
|
+
* onChange={(url) => setIconUrl(url)}
|
|
96
|
+
* onRemove={() => setIconUrl('')}
|
|
97
|
+
* />
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
const PntUpload = ({
|
|
101
|
+
value,
|
|
102
|
+
onChange,
|
|
103
|
+
onRemove,
|
|
104
|
+
onError,
|
|
105
|
+
scene,
|
|
106
|
+
pathKey,
|
|
107
|
+
context,
|
|
108
|
+
audience,
|
|
109
|
+
accept,
|
|
110
|
+
sizeLimit,
|
|
111
|
+
imagePreview = true,
|
|
112
|
+
previewStyle,
|
|
113
|
+
defaultImg,
|
|
114
|
+
fileNameStrategy,
|
|
115
|
+
useCdn,
|
|
116
|
+
getCredential,
|
|
117
|
+
normalizeCredential,
|
|
118
|
+
texts,
|
|
119
|
+
disabled = false,
|
|
120
|
+
style,
|
|
121
|
+
className
|
|
122
|
+
}) => {
|
|
123
|
+
const [uploading, setUploading] = useState(false);
|
|
124
|
+
const [percent, setPercent] = useState(0);
|
|
125
|
+
const [errorText, setErrorText] = useState('');
|
|
126
|
+
const [lastFile, setLastFile] = useState(null);
|
|
127
|
+
const finalTexts = useMemo(() => ({
|
|
128
|
+
...DEFAULT_TEXTS,
|
|
129
|
+
...texts
|
|
130
|
+
}), [texts]);
|
|
131
|
+
const acceptList = useMemo(() => toAcceptList(accept), [accept]);
|
|
132
|
+
const acceptString = useMemo(() => acceptList.join(', '), [acceptList]);
|
|
133
|
+
const uploader = usePntCosUploader({
|
|
134
|
+
scene,
|
|
135
|
+
pathKey,
|
|
136
|
+
context,
|
|
137
|
+
audience,
|
|
138
|
+
fileNameStrategy,
|
|
139
|
+
useCdn,
|
|
140
|
+
getCredential,
|
|
141
|
+
normalizeCredential
|
|
142
|
+
});
|
|
143
|
+
const reportError = (message, phase, file) => {
|
|
144
|
+
setErrorText(message);
|
|
145
|
+
onError?.(new Error(message), phase, file);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 自定义上传:TDesign 选好文件后回调,内部完成校验 + COS 直传
|
|
150
|
+
*/
|
|
151
|
+
const requestMethod = async files => {
|
|
152
|
+
const tdFile = Array.isArray(files) ? files[0] : files;
|
|
153
|
+
const file = tdFile?.raw;
|
|
154
|
+
if (!file) {
|
|
155
|
+
return {
|
|
156
|
+
status: 'fail',
|
|
157
|
+
error: 'no file',
|
|
158
|
+
response: {}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 1. 本地校验:类型 / 大小
|
|
163
|
+
if (!matchAccept(file, acceptList)) {
|
|
164
|
+
reportError(finalTexts.typeInvalid, 'validate', file);
|
|
165
|
+
return {
|
|
166
|
+
status: 'fail',
|
|
167
|
+
error: finalTexts.typeInvalid,
|
|
168
|
+
response: {}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (typeof sizeLimit === 'number' && file.size > sizeLimit) {
|
|
172
|
+
reportError(finalTexts.sizeInvalid, 'validate', file);
|
|
173
|
+
return {
|
|
174
|
+
status: 'fail',
|
|
175
|
+
error: finalTexts.sizeInvalid,
|
|
176
|
+
response: {}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. COS 直传
|
|
181
|
+
setUploading(true);
|
|
182
|
+
setPercent(0);
|
|
183
|
+
setErrorText('');
|
|
184
|
+
try {
|
|
185
|
+
const result = await uploader.upload(file, setPercent);
|
|
186
|
+
setLastFile(file);
|
|
187
|
+
onChange?.(result.fileUrl, result.cosKey, file);
|
|
188
|
+
return {
|
|
189
|
+
status: 'success',
|
|
190
|
+
response: {
|
|
191
|
+
url: result.fileUrl
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
} catch (e) {
|
|
195
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
196
|
+
setErrorText(finalTexts.uploadFailed);
|
|
197
|
+
onError?.(err, 'upload', file);
|
|
198
|
+
return {
|
|
199
|
+
status: 'fail',
|
|
200
|
+
error: err.message,
|
|
201
|
+
response: {}
|
|
202
|
+
};
|
|
203
|
+
} finally {
|
|
204
|
+
setUploading(false);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const handleRemove = e => {
|
|
208
|
+
// 阻止冒泡到 Upload 触发文件选择(DeleteIcon 与 PntImage 移除按钮均为 SVG 点击事件)
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
if (disabled || uploading) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
setLastFile(null);
|
|
214
|
+
setErrorText('');
|
|
215
|
+
onRemove?.();
|
|
216
|
+
};
|
|
217
|
+
const wrapperClassName = ['pnt-upload', imagePreview ? 'pnt-upload--image' : 'pnt-upload--file', disabled ? 'pnt-upload--disabled' : '', className || ''].filter(Boolean).join(' ');
|
|
218
|
+
const displayName = lastFile?.name || getFileNameFromUrl(value);
|
|
219
|
+
return /*#__PURE__*/_jsxs("div", {
|
|
220
|
+
className: wrapperClassName,
|
|
221
|
+
style: style,
|
|
222
|
+
children: [/*#__PURE__*/_jsx(Upload, {
|
|
223
|
+
theme: "custom",
|
|
224
|
+
requestMethod: requestMethod,
|
|
225
|
+
accept: acceptString || undefined,
|
|
226
|
+
allowUploadDuplicateFile: true,
|
|
227
|
+
disabled: disabled || uploading,
|
|
228
|
+
className: "pnt-upload__trigger",
|
|
229
|
+
children: imagePreview ? /*#__PURE__*/_jsx(PntImage, {
|
|
230
|
+
src: value,
|
|
231
|
+
loading: uploading,
|
|
232
|
+
defaultImg: defaultImg,
|
|
233
|
+
style: previewStyle,
|
|
234
|
+
texts: {
|
|
235
|
+
uploading: finalTexts.uploading
|
|
236
|
+
},
|
|
237
|
+
onRemove: disabled ? undefined : handleRemove
|
|
238
|
+
}) : /*#__PURE__*/_jsx("div", {
|
|
239
|
+
className: "pnt-upload-file",
|
|
240
|
+
children: value && !uploading ? /*#__PURE__*/_jsxs("div", {
|
|
241
|
+
className: "pnt-upload-file__row",
|
|
242
|
+
children: [/*#__PURE__*/_jsx(FileIcon, {
|
|
243
|
+
className: "pnt-upload-file__icon"
|
|
244
|
+
}), /*#__PURE__*/_jsx("span", {
|
|
245
|
+
className: "pnt-upload-file__name",
|
|
246
|
+
title: displayName,
|
|
247
|
+
children: displayName
|
|
248
|
+
}), !disabled && /*#__PURE__*/_jsx(DeleteIcon, {
|
|
249
|
+
className: "pnt-upload-file__delete",
|
|
250
|
+
onClick: handleRemove
|
|
251
|
+
})]
|
|
252
|
+
}) : uploading ? /*#__PURE__*/_jsxs("div", {
|
|
253
|
+
className: "pnt-upload-file__row pnt-upload-file__row--progress",
|
|
254
|
+
children: [/*#__PURE__*/_jsx(Progress, {
|
|
255
|
+
percentage: percent,
|
|
256
|
+
className: "pnt-upload-file__progress"
|
|
257
|
+
}), /*#__PURE__*/_jsxs("span", {
|
|
258
|
+
className: "pnt-upload-file__percent",
|
|
259
|
+
children: [finalTexts.uploading, " ", percent, "%"]
|
|
260
|
+
})]
|
|
261
|
+
}) : /*#__PURE__*/_jsxs("div", {
|
|
262
|
+
className: "pnt-upload-file__row pnt-upload-file__row--empty",
|
|
263
|
+
children: [/*#__PURE__*/_jsx(UploadIcon, {
|
|
264
|
+
className: "pnt-upload-file__icon"
|
|
265
|
+
}), /*#__PURE__*/_jsx("span", {
|
|
266
|
+
children: value ? finalTexts.reselect : finalTexts.selectFile
|
|
267
|
+
})]
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
}), errorText && /*#__PURE__*/_jsx("p", {
|
|
271
|
+
className: "pnt-upload__error",
|
|
272
|
+
children: errorText
|
|
273
|
+
})]
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
export default PntUpload;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
.pnt-upload {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
|
|
4
|
+
// TDesign Upload custom 触发器外层
|
|
5
|
+
.pnt-upload__trigger {
|
|
6
|
+
display: inline-block;
|
|
7
|
+
cursor: pointer;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 错误文案(图片 / 文件模式共用)
|
|
11
|
+
.pnt-upload__error {
|
|
12
|
+
margin: 4px 0 0;
|
|
13
|
+
color: var(--td-error-color, #d54941);
|
|
14
|
+
font-size: 12px;
|
|
15
|
+
line-height: 20px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
&--disabled {
|
|
19
|
+
.pnt-upload__trigger {
|
|
20
|
+
cursor: not-allowed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ========== 文件模式 ==========
|
|
25
|
+
.pnt-upload-file {
|
|
26
|
+
box-sizing: border-box;
|
|
27
|
+
min-width: 320px;
|
|
28
|
+
padding: 12px 16px;
|
|
29
|
+
border: 1px dashed var(--td-component-border, #dcdcdc);
|
|
30
|
+
border-radius: var(--td-radius-default, 6px);
|
|
31
|
+
background-color: var(--td-bg-color-container, #fff);
|
|
32
|
+
transition: border-color 0.2s;
|
|
33
|
+
|
|
34
|
+
&:hover {
|
|
35
|
+
border-color: var(--td-brand-color, #165dff);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&__row {
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
gap: 8px;
|
|
42
|
+
color: var(--td-text-color-primary, #1f2329);
|
|
43
|
+
font-size: 14px;
|
|
44
|
+
line-height: 22px;
|
|
45
|
+
|
|
46
|
+
&--empty {
|
|
47
|
+
justify-content: flex-start;
|
|
48
|
+
color: var(--td-text-color-secondary, #6b7280);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
&--progress {
|
|
52
|
+
align-items: center;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&__icon {
|
|
57
|
+
flex: none;
|
|
58
|
+
font-size: 20px;
|
|
59
|
+
color: var(--td-text-color-secondary, #6b7280);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&__name {
|
|
63
|
+
flex: 1;
|
|
64
|
+
overflow: hidden;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
text-overflow: ellipsis;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&__delete {
|
|
70
|
+
flex: none;
|
|
71
|
+
font-size: 18px;
|
|
72
|
+
color: var(--td-text-color-secondary, #6b7280);
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
|
|
75
|
+
&:hover {
|
|
76
|
+
color: var(--td-error-color, #d54941);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
&__progress {
|
|
81
|
+
flex: 1;
|
|
82
|
+
min-width: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
&__percent {
|
|
86
|
+
flex: none;
|
|
87
|
+
color: var(--td-text-color-secondary, #6b7280);
|
|
88
|
+
font-size: 12px;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
&--disabled {
|
|
93
|
+
.pnt-upload-file {
|
|
94
|
+
background-color: var(--td-bg-color-component-disabled, #f3f3f3);
|
|
95
|
+
|
|
96
|
+
&:hover {
|
|
97
|
+
border-color: var(--td-component-border, #dcdcdc);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -1,22 +1,62 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** 单个解锁项 */
|
|
2
|
+
export interface HighSecurityParamItem {
|
|
3
|
+
/** 字段标识(与后端约定) */
|
|
4
|
+
key: string;
|
|
5
|
+
/** 参数密文(_high_security_: 前缀) */
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
/** 高敏数据解锁请求参数(组件层友好命名,库内部转换为后端 snake_case) */
|
|
2
9
|
export interface HighSecurityRequestParams {
|
|
3
|
-
/**
|
|
10
|
+
/** 业务 ID */
|
|
4
11
|
gameId: string | number;
|
|
5
|
-
/**
|
|
12
|
+
/** 环境标识(1=dev 2=test 3=prod,以各业务现有约定为准) */
|
|
6
13
|
env: string | number;
|
|
7
|
-
/** 页面 ID(不传则默认取 location.pathname
|
|
14
|
+
/** 页面 ID(不传则默认取 location.pathname),用于操作日志和权限校验 */
|
|
8
15
|
pageId?: string;
|
|
9
|
-
/**
|
|
10
|
-
param:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
/** 需要解锁的参数列表,支持批量 */
|
|
17
|
+
param: HighSecurityParamItem[];
|
|
18
|
+
}
|
|
19
|
+
/** hook 初始化 scope:传入后 securityData/isUnlocked 自动按该业务+环境过滤 */
|
|
20
|
+
export interface UseHighSecurityOptions {
|
|
21
|
+
/** 当前业务 ID */
|
|
22
|
+
gameId?: string | number;
|
|
23
|
+
/** 当前环境 */
|
|
24
|
+
env?: string | number;
|
|
25
|
+
}
|
|
26
|
+
/** 清缓存过滤条件 */
|
|
27
|
+
export interface ClearSecurityDataFilter {
|
|
28
|
+
gameId?: string | number;
|
|
29
|
+
env?: string | number;
|
|
30
|
+
}
|
|
31
|
+
export interface UseHighSecurityResult {
|
|
32
|
+
/** 当前 scope 下已解锁的明文(key 为纯字段名);未传 scope 时为空对象 */
|
|
33
|
+
securityData: Record<string, string>;
|
|
34
|
+
/**
|
|
35
|
+
* 发起解锁
|
|
36
|
+
* @description
|
|
37
|
+
* - 已缓存的 key 直接返回缓存值,不再请求
|
|
38
|
+
* - 同一参数的在途请求自动去重
|
|
39
|
+
* - 成功:写入模块缓存并返回当次解密数组(含已缓存项)
|
|
40
|
+
* - 失败/部分失败:仅返回拿到的项(可能为空数组),不抛错
|
|
41
|
+
*/
|
|
42
|
+
requestSecurityData: (params: HighSecurityRequestParams) => Promise<HighSecurityParamItem[]>;
|
|
43
|
+
/**
|
|
44
|
+
* 清缓存:不传参清空全部;传 gameId 清空该业务所有环境;
|
|
45
|
+
* 同时传 gameId+env 只清空该业务指定环境;仅传 env 清空所有业务该环境
|
|
46
|
+
*/
|
|
47
|
+
clearSecurityData: (filter?: ClearSecurityDataFilter) => void;
|
|
48
|
+
/** 当前 scope 下某字段是否已解锁(两步式"查看 → 复制"UI 用) */
|
|
49
|
+
isUnlocked: (key: string) => boolean;
|
|
14
50
|
}
|
|
15
51
|
/**
|
|
16
|
-
* 高敏数据解锁 Hook(PntSecureBox
|
|
17
|
-
*
|
|
52
|
+
* 高敏数据解锁 Hook(PntSecureBox 内部依赖,同时对外导出供"复制"等非展示场景使用)
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```tsx
|
|
56
|
+
* // 展示场景请直接用 PntSecureBox;两步式"查看 → 复制":
|
|
57
|
+
* const { isUnlocked, requestSecurityData, securityData } = useHighSecurity({ gameId, env });
|
|
58
|
+
* const res = await requestSecurityData({ gameId, env, param: [{ key: 'SDK_KEY', value }] });
|
|
59
|
+
* if (res.length) copy(securityData.SDK_KEY);
|
|
60
|
+
* ```
|
|
18
61
|
*/
|
|
19
|
-
export declare const useHighSecurity: () =>
|
|
20
|
-
securityData: Record<string, string>;
|
|
21
|
-
requestSecurityData: ({ gameId, param, env, pageId, }: HighSecurityRequestParams) => Promise<void>;
|
|
22
|
-
};
|
|
62
|
+
export declare const useHighSecurity: (options?: UseHighSecurityOptions) => UseHighSecurityResult;
|
|
@@ -1,54 +1,196 @@
|
|
|
1
1
|
import { businessGameInfoHighSecurityDecrypt } from "../api";
|
|
2
2
|
import { usePntConfig } from "../config";
|
|
3
|
-
import {
|
|
3
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** 单个解锁项 */
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** 高敏数据解锁请求参数(组件层友好命名,库内部转换为后端 snake_case) */
|
|
8
|
+
|
|
9
|
+
/** hook 初始化 scope:传入后 securityData/isUnlocked 自动按该业务+环境过滤 */
|
|
10
|
+
|
|
11
|
+
/** 清缓存过滤条件 */
|
|
12
|
+
|
|
13
|
+
// ==========================================
|
|
14
|
+
// 模块级缓存(复合键隔离:gameId + env + key)
|
|
15
|
+
// ==========================================
|
|
16
|
+
|
|
17
|
+
const buildCacheKey = (gameId, env, key) => `${gameId}:${env}:${key}`;
|
|
18
|
+
let securityCache = new Map();
|
|
19
|
+
/** 在途请求去重:key 为 gameId+env+param 指纹 */
|
|
20
|
+
const inflightRequests = new Map();
|
|
21
|
+
const listeners = new Set();
|
|
22
|
+
const emitChange = () => {
|
|
23
|
+
listeners.forEach(listener => listener());
|
|
24
|
+
};
|
|
25
|
+
const subscribe = listener => {
|
|
26
|
+
listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const getSnapshot = () => securityCache;
|
|
32
|
+
|
|
33
|
+
/** 写入解密结果并通知所有订阅实例 */
|
|
34
|
+
const setEntries = (gameId, env, items) => {
|
|
35
|
+
if (!items.length) return;
|
|
36
|
+
const next = new Map(securityCache);
|
|
37
|
+
items.forEach(item => {
|
|
38
|
+
next.set(buildCacheKey(gameId, env, item.key), item.value);
|
|
39
|
+
});
|
|
40
|
+
securityCache = next;
|
|
41
|
+
emitChange();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 判断缓存复合键是否命中过滤条件 */
|
|
45
|
+
const matchFilter = (cacheKey, filter) => {
|
|
46
|
+
if (!filter || filter.gameId === undefined && filter.env === undefined) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const parts = cacheKey.split(':');
|
|
50
|
+
const [cachedGameId, cachedEnv] = parts;
|
|
51
|
+
if (filter.gameId !== undefined && cachedGameId !== String(filter.gameId)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (filter.env !== undefined && cachedEnv !== String(filter.env)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
};
|
|
59
|
+
const clearEntries = filter => {
|
|
60
|
+
const next = new Map();
|
|
61
|
+
securityCache.forEach((value, key) => {
|
|
62
|
+
if (!matchFilter(key, filter)) {
|
|
63
|
+
next.set(key, value);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
if (next.size !== securityCache.size) {
|
|
67
|
+
securityCache = next;
|
|
68
|
+
emitChange();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const getDefaultPageId = () => typeof window !== 'undefined' && window.location ? window.location.pathname : '';
|
|
8
72
|
|
|
9
73
|
/**
|
|
10
|
-
* 高敏数据解锁 Hook(PntSecureBox
|
|
11
|
-
*
|
|
74
|
+
* 高敏数据解锁 Hook(PntSecureBox 内部依赖,同时对外导出供"复制"等非展示场景使用)
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```tsx
|
|
78
|
+
* // 展示场景请直接用 PntSecureBox;两步式"查看 → 复制":
|
|
79
|
+
* const { isUnlocked, requestSecurityData, securityData } = useHighSecurity({ gameId, env });
|
|
80
|
+
* const res = await requestSecurityData({ gameId, env, param: [{ key: 'SDK_KEY', value }] });
|
|
81
|
+
* if (res.length) copy(securityData.SDK_KEY);
|
|
82
|
+
* ```
|
|
12
83
|
*/
|
|
13
|
-
export const useHighSecurity =
|
|
84
|
+
export const useHighSecurity = options => {
|
|
14
85
|
const {
|
|
15
86
|
http
|
|
16
87
|
} = usePntConfig();
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
88
|
+
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
89
|
+
const scopeGameId = options?.gameId === undefined ? undefined : String(options.gameId);
|
|
90
|
+
const scopeEnv = options?.env === undefined ? undefined : String(options.env);
|
|
91
|
+
const hasScope = scopeGameId !== undefined && scopeEnv !== undefined;
|
|
92
|
+
const securityData = useMemo(() => {
|
|
93
|
+
if (!hasScope) return {};
|
|
94
|
+
const prefix = `${scopeGameId}:${scopeEnv}:`;
|
|
95
|
+
const result = {};
|
|
96
|
+
snapshot.forEach((value, cacheKey) => {
|
|
97
|
+
if (cacheKey.startsWith(prefix)) {
|
|
98
|
+
// 复合键第三段起即原始 dataKey(dataKey 自身可能含冒号)
|
|
99
|
+
result[cacheKey.slice(prefix.length)] = value;
|
|
100
|
+
}
|
|
21
101
|
});
|
|
22
|
-
return
|
|
23
|
-
});
|
|
24
|
-
const
|
|
102
|
+
return result;
|
|
103
|
+
}, [snapshot, hasScope, scopeGameId, scopeEnv]);
|
|
104
|
+
const isUnlocked = useCallback(key => {
|
|
105
|
+
if (!hasScope) return false;
|
|
106
|
+
return snapshot.has(buildCacheKey(scopeGameId, scopeEnv, key));
|
|
107
|
+
}, [snapshot, hasScope, scopeGameId, scopeEnv]);
|
|
108
|
+
const requestSecurityData = useCallback(async ({
|
|
25
109
|
gameId,
|
|
26
|
-
param,
|
|
27
110
|
env,
|
|
28
|
-
pageId
|
|
111
|
+
pageId,
|
|
112
|
+
param
|
|
29
113
|
}) => {
|
|
30
|
-
|
|
114
|
+
const gid = String(gameId);
|
|
115
|
+
const eid = String(env);
|
|
116
|
+
const result = new Array(param.length);
|
|
117
|
+
const missing = [];
|
|
118
|
+
const missingIndexes = [];
|
|
119
|
+
|
|
120
|
+
// 已缓存的 key 不再请求
|
|
121
|
+
param.forEach((item, index) => {
|
|
122
|
+
const cached = snapshot.get(buildCacheKey(gid, eid, item.key));
|
|
123
|
+
if (cached !== undefined) {
|
|
124
|
+
result[index] = {
|
|
125
|
+
key: item.key,
|
|
126
|
+
value: cached
|
|
127
|
+
};
|
|
128
|
+
} else {
|
|
129
|
+
missing.push({
|
|
130
|
+
key: item.key,
|
|
131
|
+
value: item.value
|
|
132
|
+
});
|
|
133
|
+
missingIndexes.push(index);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
if (!missing.length) {
|
|
137
|
+
return result.filter(item => item !== undefined);
|
|
138
|
+
}
|
|
31
139
|
const body = {
|
|
32
140
|
game_id: Number(gameId),
|
|
33
141
|
env: Number(env),
|
|
34
|
-
page_id: pageId ||
|
|
35
|
-
param:
|
|
36
|
-
key: i.key,
|
|
37
|
-
value: i.value
|
|
38
|
-
}))
|
|
142
|
+
page_id: pageId || getDefaultPageId(),
|
|
143
|
+
param: missing
|
|
39
144
|
};
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
145
|
+
|
|
146
|
+
// 请求级并发去重:同一业务+环境+参数列表指纹在途时复用
|
|
147
|
+
const requestKey = `${gid}:${eid}:${missing.map(item => `${item.key}\u0001${item.value}`).join('\u0002')}`;
|
|
148
|
+
const existing = inflightRequests.get(requestKey);
|
|
149
|
+
if (existing) return existing;
|
|
150
|
+
const promise = (async () => {
|
|
151
|
+
try {
|
|
152
|
+
const res = await businessGameInfoHighSecurityDecrypt(http, body);
|
|
153
|
+
if (res.code === 0 && Array.isArray(res.data)) {
|
|
154
|
+
// yapi 生成类型为 Record<string, unknown>[],按运行时结构收窄
|
|
155
|
+
const decrypted = res.data.map(raw => {
|
|
156
|
+
if (raw && typeof raw === 'object' && 'key' in raw) {
|
|
157
|
+
const item = raw;
|
|
158
|
+
if (typeof item.key === 'string') {
|
|
159
|
+
return {
|
|
160
|
+
key: item.key,
|
|
161
|
+
value: String(item.value ?? '')
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}).filter(item => item !== null);
|
|
167
|
+
setEntries(gid, eid, decrypted);
|
|
168
|
+
decrypted.forEach(item => {
|
|
169
|
+
const index = missingIndexes.find(i => param[i].key === item.key);
|
|
170
|
+
if (index !== undefined) result[index] = item;
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
// eslint-disable-next-line no-console
|
|
174
|
+
console.warn('[pnt-component-frontend] high-security-decrypt 解锁失败', res?.msg ?? res?.code);
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// eslint-disable-next-line no-console
|
|
178
|
+
console.warn('[pnt-component-frontend] high-security-decrypt 请求异常', error);
|
|
179
|
+
} finally {
|
|
180
|
+
inflightRequests.delete(requestKey);
|
|
181
|
+
}
|
|
182
|
+
return result.filter(item => item !== undefined);
|
|
183
|
+
})();
|
|
184
|
+
inflightRequests.set(requestKey, promise);
|
|
185
|
+
return promise;
|
|
186
|
+
}, [http, snapshot]);
|
|
187
|
+
const clearSecurityData = useCallback(filter => {
|
|
188
|
+
clearEntries(filter);
|
|
189
|
+
}, []);
|
|
50
190
|
return {
|
|
51
191
|
securityData,
|
|
52
|
-
requestSecurityData
|
|
192
|
+
requestSecurityData,
|
|
193
|
+
clearSecurityData,
|
|
194
|
+
isUnlocked
|
|
53
195
|
};
|
|
54
196
|
};
|
package/lib/esm/index.d.ts
CHANGED
|
@@ -2,4 +2,10 @@ export { PntProvider, usePntConfig, type PntConfig, type commonEesType, } from '
|
|
|
2
2
|
export { default as PntImage } from './components/PntImage';
|
|
3
3
|
export { default as PntBusinessSelect } from './components/PntBusinessSelect';
|
|
4
4
|
export { default as PntSecureBox } from './components/PntSecureBox';
|
|
5
|
+
export { default as PntUpload } from './components/PntUpload';
|
|
6
|
+
export type { PntUploadProps, PntUploadTexts, PntUploadErrorPhase, } from './components/PntUpload';
|
|
7
|
+
export { default as usePntCosUploader, createPntCosUploader, normalizeSnakeCredential, type PntCosUploadOptions, type PntCosUploadResult, type PntCosUploader as PntCosUploaderInstance, type PntCosRequestOverrides, type FileNameStrategy, type CosScene, type GetCosCredentialParams, type NormalizedCosCredential, type NormalizeCredential, } from './components/PntCosUploader';
|
|
8
|
+
export { useHighSecurity } from './hooks/useHighSecurity';
|
|
9
|
+
export type { HighSecurityParamItem, HighSecurityRequestParams, UseHighSecurityOptions, UseHighSecurityResult, ClearSecurityDataFilter, } from './hooks/useHighSecurity';
|
|
10
|
+
export { isSecurityData } from './utils';
|
|
5
11
|
export { default as PntGeneralLayout } from './components/PntGeneralLayout';
|