@jiangood/open-admin 3.0.15 → 3.1.1
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/package.json +15 -2
- package/src/framework/biz/OrgTree.tsx +10 -6
- package/src/framework/biz/PermActions.tsx +5 -3
- package/src/framework/biz/RoleTree.tsx +10 -6
- package/src/framework/biz/index.tsx +1 -0
- package/src/framework/components/ContextMenu/index.tsx +17 -1
- package/src/framework/components/DownloadModal/index.tsx +40 -33
- package/src/framework/components/DrawerForm/index.tsx +108 -0
- package/src/framework/components/FormModal/index.tsx +16 -9
- package/src/framework/components/NamedIcon/index.tsx +2 -2
- package/src/framework/components/OrgSwitcher/index.tsx +6 -7
- package/src/framework/components/PageLoading/index.tsx +3 -3
- package/src/framework/components/ProTable/index.tsx +28 -25
- package/src/framework/components/ValueType/index.tsx +1 -1
- package/src/framework/components/index.ts +1 -0
- package/src/framework/config/themeConfig.ts +5 -5
- package/src/framework/fields/BaseRemoteSelect/index.tsx +23 -21
- package/src/framework/fields/FieldBoolean/index.tsx +6 -6
- package/src/framework/fields/FieldDate/index.tsx +10 -10
- package/src/framework/fields/FieldDateRange/index.tsx +20 -20
- package/src/framework/fields/FieldDictSelect/index.tsx +2 -2
- package/src/framework/fields/FieldEditor/index.tsx +1 -1
- package/src/framework/fields/FieldNumberRange/index.tsx +16 -17
- package/src/framework/fields/FieldPercent/index.tsx +2 -2
- package/src/framework/fields/FieldRemoteSelect/index.tsx +4 -4
- package/src/framework/fields/FieldRemoteTree/index.tsx +1 -1
- package/src/framework/fields/FieldRemoteTreeCascader/index.tsx +1 -1
- package/src/framework/fields/FieldRemoteTreeSelect/index.tsx +1 -1
- package/src/framework/fields/FieldSysOrgTree/index.tsx +1 -1
- package/src/framework/fields/FieldSysOrgTreeSelect/index.tsx +1 -1
- package/src/framework/fields/FieldTable/index.tsx +15 -13
- package/src/framework/fields/FieldTableSelect/index.tsx +5 -5
- package/src/framework/fields/FieldUploadFile/index.tsx +3 -3
- package/src/framework/fields/FieldUploadImage/index.tsx +31 -29
- package/src/framework/fields/types.ts +0 -2
- package/src/framework/router/PageFrame.tsx +1 -1
- package/src/framework/router/matcher.ts +2 -2
- package/src/framework/utils/ArrayUtils.ts +2 -2
- package/src/framework/utils/DateUtils.ts +1 -1
- package/src/framework/utils/{system/DictUtils.ts → DictUtils.ts} +1 -1
- package/src/framework/utils/EventBus.ts +7 -7
- package/src/framework/utils/HttpClient.ts +283 -0
- package/src/framework/utils/{system/PageUtils.ts → PageUtils.ts} +4 -4
- package/src/framework/utils/{system/PermUtils.ts → PermUtils.ts} +3 -3
- package/src/framework/utils/StringUtils.ts +10 -8
- package/src/framework/utils/TreeUtils.ts +3 -3
- package/src/framework/utils/UrlUtils.ts +2 -2
- package/src/framework/utils/index.ts +4 -1
- package/src/framework/views/ViewApproveStatus/index.tsx +1 -2
- package/src/framework/views/ViewBoolean/index.tsx +4 -1
- package/src/framework/views/ViewFile/index.tsx +4 -4
- package/src/framework/views/ViewImage/index.tsx +2 -2
- package/src/framework/views/ViewPassword/index.tsx +3 -2
- package/src/framework/views/ViewText/index.tsx +12 -3
- package/src/framework/views/types.ts +2 -4
- package/src/layouts/admin/AdminTabs.jsx +9 -0
- package/src/layouts/admin/AdminTabs.less +2 -2
- package/src/layouts/admin/HeaderRight.jsx +8 -7
- package/src/layouts/admin/TabLayout.jsx +2 -2
- package/src/layouts/admin/index.jsx +15 -11
- package/src/layouts/admin/index.less +16 -1
- package/src/layouts/index.tsx +24 -18
- package/src/pages/404.jsx +1 -1
- package/src/pages/article/$code.jsx +4 -4
- package/src/pages/index.jsx +1 -1
- package/src/pages/public/login.jsx +34 -27
- package/src/pages/public/test.jsx +1 -1
- package/src/pages/standalone/forceUpdatePwd.jsx +9 -7
- package/src/pages/system/article/index.jsx +9 -8
- package/src/pages/system/dict/index.jsx +19 -16
- package/src/pages/system/file/index.jsx +9 -9
- package/src/pages/system/job/index.jsx +17 -17
- package/src/pages/system/log/index.jsx +5 -7
- package/src/pages/system/org/index.jsx +31 -18
- package/src/pages/system/role/index.jsx +13 -12
- package/src/pages/system/role/rolePerm.jsx +25 -21
- package/src/pages/system/user/index.jsx +35 -33
- package/src/pages/system/user/userPerm.jsx +12 -12
- package/src/pages/userCenter/index.jsx +11 -9
- package/src/pages/userCenter/permView.jsx +7 -7
- package/src/framework/utils/system/HttpUtils.ts +0 -97
- package/src/framework/utils/system/index.ts +0 -4
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import type {AxiosProgressEvent, AxiosRequestConfig} from "axios";
|
|
3
|
+
import qs from 'qs';
|
|
4
|
+
import {message as messageApi} from "antd";
|
|
5
|
+
import {EventBus} from "./EventBus";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 单一 Promise 风格 HTTP 工具类(所有请求方法均返回 Promise,无 success/error 回调)
|
|
9
|
+
*
|
|
10
|
+
* 核心规则:
|
|
11
|
+
* - 请求方法返回 Promise<AjaxBody<T>>:成功 resolve 整个 AjaxResult 响应体,失败 reject(AjaxError {code, message})
|
|
12
|
+
* - 成功 resolve 的是原始响应体 {success, code, data, message, traceId, ...}(extData 等附加字段在顶层),
|
|
13
|
+
* 业务取数据用 rs.data,取提示文案用 rs.message
|
|
14
|
+
* - 成功且后端返回了 message 时自动 message.success(body.message),传 {toastSuccess: false} 可关闭
|
|
15
|
+
* - 失败默认自动 console.warn + message.error,并 reject(AjaxError);传 {toastError: false} 可完全静默(仅 reject)
|
|
16
|
+
* - 返回值被忽略也安全:内部已对返回的 Promise 挂 no-op catch,不会产生 unhandled rejection
|
|
17
|
+
* - reject 的 AjaxError {code, message}:业务失败 code 为后端业务码,网络异常 code 为 HTTP 状态码,message 始终为人类可读提示
|
|
18
|
+
* - HTTP 401 或业务 code=401 均广播 loginExpired(全局关注点),弹错仍按 toastError 决定
|
|
19
|
+
*
|
|
20
|
+
* 用法:
|
|
21
|
+
* ```ts
|
|
22
|
+
* // 提交:成功自动弹后端 message(如"更新成功"),失败自动弹错并 reject(可用 await 感知结果,如 FormModal 提交失败保持弹窗)
|
|
23
|
+
* await HttpClient.post('admin/sysUser/create', values);
|
|
24
|
+
*
|
|
25
|
+
* // 取返回数据与后端提示文案
|
|
26
|
+
* const {data, message} = await HttpClient.post('admin/sysUser/create', values);
|
|
27
|
+
*
|
|
28
|
+
* // 成功自控提示(关闭自动弹后由业务决定)
|
|
29
|
+
* await HttpClient.post('admin/sysUser/delete', {id}, null, {toastSuccess: false});
|
|
30
|
+
* messageApi.success('删除成功');
|
|
31
|
+
* reload();
|
|
32
|
+
*
|
|
33
|
+
* // 失败由调用方接管,不弹错(静默探测)
|
|
34
|
+
* HttpClient.get('admin/public/site-info', null, {toastError: false})
|
|
35
|
+
* .then(({data}) => render(data))
|
|
36
|
+
* .catch((e) => myHandle(e));
|
|
37
|
+
*
|
|
38
|
+
* // $.ajax 形态
|
|
39
|
+
* HttpClient.ajax({url: 'admin/sysUser/page', method: 'GET', params}).then((rs) => render(rs.data));
|
|
40
|
+
*
|
|
41
|
+
* // 下载(返回 Promise<Blob>,成功已触发浏览器保存)
|
|
42
|
+
* HttpClient.download({url: 'admin/sysUser/export', method: 'POST', data: {deptId: 1},
|
|
43
|
+
* onDownloadProgress: ({loaded, total}) => console.log(loaded, total)})
|
|
44
|
+
* .catch(() => {});
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
/** 统一的错误数据格式:业务失败与网络异常都包装成此结构传给 reject 的 error */
|
|
48
|
+
export interface AjaxError {
|
|
49
|
+
code?: number | string;
|
|
50
|
+
message: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 后端统一响应结构 AjaxResult;putExtData 附加字段在顶层,data 为业务数据 */
|
|
54
|
+
export interface AjaxBody<T = unknown> {
|
|
55
|
+
success: boolean;
|
|
56
|
+
code: number;
|
|
57
|
+
data: T;
|
|
58
|
+
message?: string;
|
|
59
|
+
traceId?: string;
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 请求选项:headers 自定义请求头;toastError=false 时失败不自动弹错(完全静默,仅 reject);toastSuccess=false 时成功不自动弹后端 message */
|
|
64
|
+
export interface RequestOptions {
|
|
65
|
+
headers?: AxiosRequestConfig['headers'];
|
|
66
|
+
toastError?: boolean;
|
|
67
|
+
toastSuccess?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface AjaxSettings {
|
|
71
|
+
url: string;
|
|
72
|
+
method?: 'GET' | 'POST';
|
|
73
|
+
params?: unknown;
|
|
74
|
+
data?: unknown;
|
|
75
|
+
headers?: AxiosRequestConfig['headers'];
|
|
76
|
+
toastError?: boolean;
|
|
77
|
+
toastSuccess?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface DownloadSettings {
|
|
81
|
+
url: string;
|
|
82
|
+
method?: 'GET' | 'POST';
|
|
83
|
+
params?: unknown;
|
|
84
|
+
data?: unknown;
|
|
85
|
+
fileName?: string;
|
|
86
|
+
onDownloadProgress?: (progress: {loaded: number; total: number}) => void;
|
|
87
|
+
toastError?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const axiosInstance = axios.create({
|
|
91
|
+
baseURL: import.meta.env.VITE_SERVER_SERVLET_CONTEXT_PATH,
|
|
92
|
+
withCredentials: true,
|
|
93
|
+
headers: {'Content-Type': 'application/json'},
|
|
94
|
+
paramsSerializer: (params: Record<string, unknown>) => qs.stringify(params, {indices: false})
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export class HttpClient {
|
|
98
|
+
static ajax<T = unknown>(settings: AjaxSettings): Promise<AjaxBody<T>> {
|
|
99
|
+
const {url, method = 'GET', params, data, headers, toastError, toastSuccess} = settings;
|
|
100
|
+
return HttpClient.coreRequest<T>({
|
|
101
|
+
url, method, params, data, headers, toastError, toastSuccess
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static get<T = unknown>(url: string, params: unknown = null, opts?: RequestOptions): Promise<AjaxBody<T>> {
|
|
106
|
+
return HttpClient.ajax<T>({url, method: 'GET', params, ...opts});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static post<T = unknown>(url: string, data: unknown = null, params: unknown = null, opts?: RequestOptions): Promise<AjaxBody<T>> {
|
|
110
|
+
return HttpClient.ajax<T>({url, method: 'POST', data, params, ...opts});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static postForm<T = unknown>(url: string, data: unknown, opts?: RequestOptions): Promise<AjaxBody<T>> {
|
|
114
|
+
return HttpClient.ajax<T>({
|
|
115
|
+
url,
|
|
116
|
+
method: 'POST',
|
|
117
|
+
data: qs.stringify(data),
|
|
118
|
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
|
119
|
+
...opts
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 下载文件(responseType: blob),返回 Promise<Blob>。
|
|
125
|
+
* 成功即触发浏览器保存并 resolve(blob);JSON 错误响应自动识别为失败并 reject;
|
|
126
|
+
* 文件名优先 settings.fileName,否则从 Content-Disposition 解析。
|
|
127
|
+
* 失败默认自动弹错,传 {toastError: false} 可静默(仅 reject)。
|
|
128
|
+
*/
|
|
129
|
+
static download(settings: DownloadSettings): Promise<Blob> {
|
|
130
|
+
const {url, method = 'GET', params, data, fileName, onDownloadProgress, toastError = true} = settings;
|
|
131
|
+
|
|
132
|
+
return new Promise<Blob>((resolve, reject) => {
|
|
133
|
+
axiosInstance({
|
|
134
|
+
url: HttpClient.prefixUrl(url),
|
|
135
|
+
method,
|
|
136
|
+
params,
|
|
137
|
+
data,
|
|
138
|
+
responseType: 'blob',
|
|
139
|
+
onDownloadProgress: (progressEvent: AxiosProgressEvent) => {
|
|
140
|
+
if (onDownloadProgress) {
|
|
141
|
+
onDownloadProgress({loaded: progressEvent.loaded, total: progressEvent.total});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}).then((response) => {
|
|
145
|
+
const blob: Blob = response.data;
|
|
146
|
+
|
|
147
|
+
if (blob.type === 'application/json') {
|
|
148
|
+
const reader = new FileReader();
|
|
149
|
+
reader.onerror = () => HttpClient.fail(url, toastError, reject, {message: '读取下载数据失败'}, '下载失败');
|
|
150
|
+
reader.onload = () => {
|
|
151
|
+
try {
|
|
152
|
+
const rs = JSON.parse(reader.result as string);
|
|
153
|
+
if (rs.code === 401) {
|
|
154
|
+
EventBus.emit('loginExpired');
|
|
155
|
+
}
|
|
156
|
+
HttpClient.fail(url, toastError, reject, {code: rs.code, message: rs.message || '下载失败'}, '下载失败');
|
|
157
|
+
} catch {
|
|
158
|
+
HttpClient.fail(url, toastError, reject, {message: '解析错误响应失败'}, '下载失败');
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
reader.readAsText(blob, 'utf-8');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let filename = fileName;
|
|
166
|
+
if (!filename) {
|
|
167
|
+
const contentDisposition = response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
|
168
|
+
if (contentDisposition) {
|
|
169
|
+
const match = /filename\*?=(?:['"]?)(?:UTF-8''|)(.+?)(?:['"]?$|;)/i.exec(contentDisposition);
|
|
170
|
+
let parsedName = match?.[1] ? match[1].trim() : 'download.file';
|
|
171
|
+
try {
|
|
172
|
+
parsedName = decodeURIComponent(parsedName.replaceAll('"', ''));
|
|
173
|
+
} catch {
|
|
174
|
+
parsedName = parsedName.replaceAll('"', '');
|
|
175
|
+
}
|
|
176
|
+
filename = parsedName;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const objectUrl = window.URL.createObjectURL(new Blob([blob]));
|
|
181
|
+
const link = document.createElement('a');
|
|
182
|
+
link.style.display = 'none';
|
|
183
|
+
link.href = objectUrl;
|
|
184
|
+
link.download = filename || 'download.file';
|
|
185
|
+
document.body.appendChild(link);
|
|
186
|
+
link.click();
|
|
187
|
+
link.remove();
|
|
188
|
+
window.URL.revokeObjectURL(objectUrl);
|
|
189
|
+
resolve(blob);
|
|
190
|
+
}).catch((e: unknown) => {
|
|
191
|
+
HttpClient.fail(url, toastError, reject, e, '下载失败');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 从 axios 异常/Error/AjaxError 中提取人类可读错误消息。
|
|
198
|
+
* catch 收到的已是 AjaxError,直接取 e.message 即可;此方法供传原始异常的场景兜底。
|
|
199
|
+
*/
|
|
200
|
+
static errToMsg(e: unknown, defaultMsg = '操作失败'): string {
|
|
201
|
+
if (axios.isAxiosError(e)) {
|
|
202
|
+
const responseData = e.response?.data;
|
|
203
|
+
const status = e.response?.status;
|
|
204
|
+
if (status === 504) return '504 请求后端服务失败';
|
|
205
|
+
if (responseData?.message) return responseData.message;
|
|
206
|
+
if (e.message) return e.message;
|
|
207
|
+
return defaultMsg;
|
|
208
|
+
}
|
|
209
|
+
if (e instanceof Error) return e.message || defaultMsg;
|
|
210
|
+
if (e && typeof e === 'object' && (e as {message?: unknown}).message) return (e as {message: string}).message;
|
|
211
|
+
return defaultMsg;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 把任意异常(AxiosError / Error / AjaxError / 业务消息)统一包装成 AjaxError */
|
|
215
|
+
private static toAjaxError(e: unknown, defaultMsg = '操作失败'): AjaxError {
|
|
216
|
+
if (e && typeof e === 'object' && typeof (e as {message?: unknown}).message === 'string') {
|
|
217
|
+
const ajaxErr = e as {code?: number | string; message: string};
|
|
218
|
+
return {code: ajaxErr.code, message: ajaxErr.message};
|
|
219
|
+
}
|
|
220
|
+
return {message: HttpClient.errToMsg(e, defaultMsg)};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private static prefixUrl(url: string): string {
|
|
224
|
+
return url.startsWith('admin') ? '/' + url : url;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private static coreRequest<T = unknown>(settings: AjaxSettings): Promise<AjaxBody<T>> {
|
|
228
|
+
const {url, method, params, data, headers, toastError = true, toastSuccess = true} = settings;
|
|
229
|
+
const config: AxiosRequestConfig = {url: HttpClient.prefixUrl(url), method, params, data, headers};
|
|
230
|
+
|
|
231
|
+
// FormData 请求交由浏览器自动生成带 boundary 的 multipart Content-Type
|
|
232
|
+
if (typeof FormData !== 'undefined' && data instanceof FormData) {
|
|
233
|
+
config.headers = {...config.headers, 'Content-Type': undefined};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const promise = new Promise<AjaxBody<T>>((resolve, reject) => {
|
|
237
|
+
axiosInstance(config).then((response) => {
|
|
238
|
+
const body = response.data as AjaxBody<T>;
|
|
239
|
+
const {success: ok, message} = body;
|
|
240
|
+
if (ok == undefined) {
|
|
241
|
+
resolve(body);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (!ok) {
|
|
245
|
+
if (body.code === 401) {
|
|
246
|
+
EventBus.emit('loginExpired');
|
|
247
|
+
}
|
|
248
|
+
HttpClient.fail(url, toastError, reject, {code: body.code, message: message || '操作失败'});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (toastSuccess && message) {
|
|
252
|
+
messageApi.success(message);
|
|
253
|
+
}
|
|
254
|
+
resolve(body);
|
|
255
|
+
}).catch((e: unknown) => {
|
|
256
|
+
HttpClient.fail(url, toastError, reject, e);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// 调用方忽略返回值时兜底,避免 unhandled rejection;await 的调用方仍能正常收到 reject
|
|
261
|
+
promise.catch(() => {});
|
|
262
|
+
return promise;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 统一的失败处理:HTTP 401 广播 loginExpired 并弹"登录过期",其余提取可读消息后按 toastError 决定是否弹错,最终 reject(AjaxError) */
|
|
266
|
+
private static fail(url: string, toastError: boolean, reject: (reason?: unknown) => void, e: unknown, label = '请求失败'): void {
|
|
267
|
+
if (axios.isAxiosError(e) && e.response?.status === 401) {
|
|
268
|
+
EventBus.emit('loginExpired');
|
|
269
|
+
const err: AjaxError = {code: 401, message: '登录过期'};
|
|
270
|
+
if (toastError) {
|
|
271
|
+
messageApi.error('登录过期');
|
|
272
|
+
}
|
|
273
|
+
reject(err);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const err = HttpClient.toAjaxError(e);
|
|
277
|
+
if (toastError) {
|
|
278
|
+
console.warn(`[HttpClient] ${label}: ${url}`, err.message);
|
|
279
|
+
messageApi.error(err.message);
|
|
280
|
+
}
|
|
281
|
+
reject(err);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {history} from '
|
|
2
|
-
import {StringUtils} from "
|
|
3
|
-
import {UrlUtils} from "
|
|
4
|
-
import {EventBus} from "
|
|
1
|
+
import {history} from '../router';
|
|
2
|
+
import {StringUtils} from "./StringUtils";
|
|
3
|
+
import {UrlUtils} from "./UrlUtils";
|
|
4
|
+
import {EventBus} from "./EventBus";
|
|
5
5
|
|
|
6
6
|
export class PageUtils {
|
|
7
7
|
static redirectToLogin(): void {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {GlobalData} from '
|
|
2
|
-
import {ArrayUtils} from '
|
|
1
|
+
import {GlobalData} from '../GlobalData';
|
|
2
|
+
import {ArrayUtils} from './ArrayUtils';
|
|
3
3
|
|
|
4
4
|
export class PermUtils {
|
|
5
5
|
static getPermissions(): string[] {
|
|
@@ -13,7 +13,7 @@ export class PermUtils {
|
|
|
13
13
|
const permissions = PermUtils.getPermissions();
|
|
14
14
|
if (permissions.length === 0) return false;
|
|
15
15
|
if (ArrayUtils.contains(permissions, "*")) return true;
|
|
16
|
-
return permissions.
|
|
16
|
+
return permissions.includes(perm);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
static isPermitted(p: string | null | undefined): boolean {
|
|
@@ -3,14 +3,14 @@ export class StringUtils {
|
|
|
3
3
|
static readonly ISO_SPLITTER: string = "/";
|
|
4
4
|
|
|
5
5
|
static removePrefix(str: string | null | undefined, ch: string): string {
|
|
6
|
-
if (str
|
|
6
|
+
if (str?.startsWith(ch)) {
|
|
7
7
|
return str.substring(ch.length);
|
|
8
8
|
}
|
|
9
9
|
return str ?? '';
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
static removeSuffix(str: string | null | undefined, ch: string): string {
|
|
13
|
-
if (str
|
|
13
|
+
if (str?.endsWith(ch)) {
|
|
14
14
|
return str.substring(0, str.length - ch.length);
|
|
15
15
|
}
|
|
16
16
|
return str ?? '';
|
|
@@ -18,9 +18,11 @@ export class StringUtils {
|
|
|
18
18
|
|
|
19
19
|
static random(length: number): string {
|
|
20
20
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
21
|
+
const values = new Uint32Array(length);
|
|
22
|
+
crypto.getRandomValues(values);
|
|
21
23
|
let result = '';
|
|
22
24
|
for (let i = 0; i < length; i++) {
|
|
23
|
-
result += characters.charAt(
|
|
25
|
+
result += characters.charAt(values[i] % characters.length);
|
|
24
26
|
}
|
|
25
27
|
return result;
|
|
26
28
|
}
|
|
@@ -61,7 +63,7 @@ export class StringUtils {
|
|
|
61
63
|
static getWidth(str: string | null | undefined): number {
|
|
62
64
|
if (str == null || str.length === 0) return 0;
|
|
63
65
|
return str.split('').reduce((pre, cur) => {
|
|
64
|
-
const charCode = cur.
|
|
66
|
+
const charCode = cur.codePointAt(0);
|
|
65
67
|
return pre + (charCode >= 0 && charCode <= 128 ? 1 : 2);
|
|
66
68
|
}, 0);
|
|
67
69
|
}
|
|
@@ -69,7 +71,7 @@ export class StringUtils {
|
|
|
69
71
|
static cutByWidth(str: string, maxWidth: number): string {
|
|
70
72
|
let showLength = 0;
|
|
71
73
|
return str.split('').reduce((pre, cur) => {
|
|
72
|
-
const charCode = cur.
|
|
74
|
+
const charCode = cur.codePointAt(0);
|
|
73
75
|
const charWidth = (charCode >= 0 && charCode <= 128) ? 1 : 2;
|
|
74
76
|
if (showLength + charWidth <= maxWidth) {
|
|
75
77
|
showLength += charWidth;
|
|
@@ -88,17 +90,17 @@ export class StringUtils {
|
|
|
88
90
|
return StringUtils.cutByWidth(str, len) + suffix;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
static isString(value:
|
|
93
|
+
static isString(value: unknown): value is string {
|
|
92
94
|
return typeof value === 'string';
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
static split(str:
|
|
97
|
+
static split(str: string | string[] | null | undefined, sp: string): string[] {
|
|
96
98
|
if (str == null || str.length === 0) return [];
|
|
97
99
|
if (Array.isArray(str)) return str;
|
|
98
100
|
return str.split(sp);
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
static join(arr:
|
|
103
|
+
static join(arr: unknown, sp: string): string {
|
|
102
104
|
if (arr == null || !Array.isArray(arr)) {
|
|
103
105
|
return '';
|
|
104
106
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export interface TreeNode<T =
|
|
1
|
+
export interface TreeNode<T = unknown> {
|
|
2
2
|
id: string | number;
|
|
3
3
|
pid: string | number | null;
|
|
4
4
|
children?: TreeNode<T>[];
|
|
5
|
-
[key: string]:
|
|
5
|
+
[key: string]: unknown;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export class TreeUtils {
|
|
@@ -19,7 +19,7 @@ export class TreeUtils {
|
|
|
19
19
|
static findByKey<T extends TreeNode>(key: string | number, list: T[], keyName: keyof T | 'id' = 'id'): T | undefined {
|
|
20
20
|
for (const item of list) {
|
|
21
21
|
if (item[keyName as keyof T] === key) return item;
|
|
22
|
-
if (item.children
|
|
22
|
+
if (item.children?.length) {
|
|
23
23
|
const rs = TreeUtils.findByKey(key, item.children as T[], keyName);
|
|
24
24
|
if (rs) return rs;
|
|
25
25
|
}
|
|
@@ -28,7 +28,7 @@ export class UrlUtils {
|
|
|
28
28
|
if (!params) return "";
|
|
29
29
|
const buffer: string[] = [];
|
|
30
30
|
for (const k in params) {
|
|
31
|
-
if (Object.
|
|
31
|
+
if (Object.hasOwn(params, k)) {
|
|
32
32
|
const v = params[k];
|
|
33
33
|
if (v !== null && v !== undefined) {
|
|
34
34
|
buffer.push(`${k}=${v}`);
|
|
@@ -67,6 +67,6 @@ export class UrlUtils {
|
|
|
67
67
|
static contextPath(path: string): string {
|
|
68
68
|
const base = import.meta.env.VITE_SERVER_SERVLET_CONTEXT_PATH;
|
|
69
69
|
if (base === '/' || base === '') return path;
|
|
70
|
-
return base.replace(/\/+$/, '') + path;
|
|
70
|
+
return base.replace(/\/+$/, '') + path; // NOSONAR: 需去除尾部斜杠,正则已最简
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -7,6 +7,9 @@ export * from "./TreeUtils";
|
|
|
7
7
|
export * from './StorageUtils'
|
|
8
8
|
export * from './DeviceUtils'
|
|
9
9
|
export * from './ObjectUtils'
|
|
10
|
-
export * from './
|
|
10
|
+
export * from './DictUtils'
|
|
11
|
+
export * from './PermUtils'
|
|
12
|
+
export * from './PageUtils'
|
|
13
|
+
export * from './HttpClient'
|
|
11
14
|
export * from './MessageApi'
|
|
12
15
|
export * from './ModalApi'
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import {DictUtils} from "../../utils";
|
|
2
|
-
import {Tag} from "antd";
|
|
3
2
|
import {Component} from "react";
|
|
4
3
|
import type {ViewProps} from "../types";
|
|
5
4
|
|
|
6
|
-
export class ViewApproveStatus extends Component<ViewProps<
|
|
5
|
+
export class ViewApproveStatus extends Component<ViewProps<string>> {
|
|
7
6
|
render() {
|
|
8
7
|
const {value} = this.props;
|
|
9
8
|
return DictUtils.dictTag('approveStatus', value);
|
|
@@ -4,6 +4,9 @@ import type {ViewProps} from "../types";
|
|
|
4
4
|
export class ViewBoolean extends React.Component<ViewProps<boolean>> {
|
|
5
5
|
render() {
|
|
6
6
|
const {value} = this.props;
|
|
7
|
-
|
|
7
|
+
if (value == null) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
return value ? '是' : '否';
|
|
8
11
|
}
|
|
9
12
|
}
|
|
@@ -23,8 +23,8 @@ export class ViewFile extends React.Component {
|
|
|
23
23
|
return <iframe
|
|
24
24
|
src={url}
|
|
25
25
|
width='99%'
|
|
26
|
-
|
|
27
|
-
style={{height}}
|
|
26
|
+
title="文件预览"
|
|
27
|
+
style={{height, border: 0}}
|
|
28
28
|
/>
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -34,8 +34,8 @@ export class ViewFile extends React.Component {
|
|
|
34
34
|
<iframe
|
|
35
35
|
src={url}
|
|
36
36
|
width='99%'
|
|
37
|
-
|
|
38
|
-
style={{height}}
|
|
37
|
+
title="文件预览"
|
|
38
|
+
style={{height, border: 0}}
|
|
39
39
|
/>
|
|
40
40
|
</div>
|
|
41
41
|
});
|
|
@@ -16,7 +16,7 @@ import {UrlUtils} from '../../utils';
|
|
|
16
16
|
*/
|
|
17
17
|
export class ViewImage extends React.Component {
|
|
18
18
|
|
|
19
|
-
static previewGroupProps = {
|
|
19
|
+
static readonly previewGroupProps = {
|
|
20
20
|
preview: {
|
|
21
21
|
scaleStep: 0.3,
|
|
22
22
|
},
|
|
@@ -61,7 +61,7 @@ export class ViewImage extends React.Component {
|
|
|
61
61
|
|
|
62
62
|
return (
|
|
63
63
|
<Image.PreviewGroup preview={previewConfig}>
|
|
64
|
-
{items.map((item
|
|
64
|
+
{items.map((item) => (
|
|
65
65
|
<Image
|
|
66
66
|
key={item.url}
|
|
67
67
|
src={item.thumb}
|
|
@@ -16,9 +16,10 @@ export class ViewPassword extends React.Component {
|
|
|
16
16
|
const visible = this.state.visible;
|
|
17
17
|
return <Space>
|
|
18
18
|
<span>{this.state.visible ? v : '******'}</span>
|
|
19
|
-
<
|
|
19
|
+
<button type="button" style={{background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer'}}
|
|
20
|
+
onClick={() => this.setState({visible: !visible})}>
|
|
20
21
|
{visible ? <EyeOutlined/> : <EyeInvisibleOutlined/>}
|
|
21
|
-
</
|
|
22
|
+
</button>
|
|
22
23
|
</Space>
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -8,7 +8,7 @@ export class ViewText extends React.Component {
|
|
|
8
8
|
modalOpen: false,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
static defaultProps = {
|
|
11
|
+
static readonly defaultProps = {
|
|
12
12
|
ellipsis: false,
|
|
13
13
|
maxLength: 15
|
|
14
14
|
}
|
|
@@ -27,9 +27,18 @@ export class ViewText extends React.Component {
|
|
|
27
27
|
const short = StringUtils.ellipsis(value, maxLength);
|
|
28
28
|
return (
|
|
29
29
|
<Tooltip placement="topLeft" title={value}>
|
|
30
|
-
<
|
|
30
|
+
<button type="button"
|
|
31
|
+
style={{
|
|
32
|
+
background: 'none',
|
|
33
|
+
border: 'none',
|
|
34
|
+
borderBottom: '1px dashed #d9d9d9',
|
|
35
|
+
padding: 0,
|
|
36
|
+
font: 'inherit',
|
|
37
|
+
cursor: 'pointer',
|
|
38
|
+
}}
|
|
39
|
+
onClick={() => this.setState({modalOpen: true})}>
|
|
31
40
|
{short}
|
|
32
|
-
</
|
|
41
|
+
</button>
|
|
33
42
|
<Modal open={this.state.modalOpen} title="长文本内容" width={700} footer={null}
|
|
34
43
|
onCancel={() => this.setState({modalOpen: false})}>
|
|
35
44
|
<div style={{height: 500, overflowY: 'auto'}}>{value}</div>
|
|
@@ -15,12 +15,10 @@ export interface ViewProps<T> {
|
|
|
15
15
|
* 通用视图组件类类型
|
|
16
16
|
* @template T - value 的类型
|
|
17
17
|
*/
|
|
18
|
-
export
|
|
19
|
-
}
|
|
18
|
+
export type ViewComponent<T> = React.ComponentClass<ViewProps<T>>;
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* 通用视图组件函数类型
|
|
23
22
|
* @template T - value 的类型
|
|
24
23
|
*/
|
|
25
|
-
export
|
|
26
|
-
}
|
|
24
|
+
export type ViewFunctionComponent<T> = React.FC<ViewProps<T>>;
|
|
@@ -43,7 +43,16 @@ export class AdminTabs extends React.Component {
|
|
|
43
43
|
<div
|
|
44
44
|
key={tab.key}
|
|
45
45
|
className={`admin-tab${tab.key === activeKey ? ' active' : ''}`}
|
|
46
|
+
role="tab"
|
|
47
|
+
tabIndex={tab.key === activeKey ? 0 : -1}
|
|
48
|
+
aria-selected={tab.key === activeKey}
|
|
46
49
|
onClick={() => onChange(tab.key)}
|
|
50
|
+
onKeyDown={e => {
|
|
51
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
onChange(tab.key);
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
47
56
|
onContextMenu={e => this.handleContextMenu(e, tab.key)}
|
|
48
57
|
>
|
|
49
58
|
<span className="admin-tab-label">{tab.label}</span>
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
height: 16px;
|
|
53
53
|
font-size: 12px;
|
|
54
54
|
border-radius: 50%;
|
|
55
|
-
color: rgba(0, 0, 0, 0.45);
|
|
55
|
+
color: rgba(0, 0, 0, 0.45); /* NOSONAR: 次要文本颜色,设计如此 */
|
|
56
56
|
|
|
57
57
|
&:hover {
|
|
58
58
|
background: rgba(0, 0, 0, 0.08);
|
|
59
|
-
color: rgba(0, 0, 0, 0.88);
|
|
59
|
+
color: rgba(0, 0, 0, 0.88); /* NOSONAR: 悬停态主文本色,对比度设计如此 */
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {Avatar, Dropdown, Modal} from "antd";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import {DeviceUtils,
|
|
3
|
+
import {DeviceUtils, HttpClient, PageUtils, GlobalData, getToken, history, EventBus, ARTICLE_HEADER_AVATAR_DROPDOWN, ARTICLE_HEADER_RIGHT} from "../../framework";
|
|
4
4
|
|
|
5
5
|
export class HeaderRight extends React.Component {
|
|
6
6
|
|
|
@@ -28,10 +28,10 @@ export class HeaderRight extends React.Component {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
logout = () => {
|
|
31
|
-
|
|
31
|
+
HttpClient.post('admin/auth/logout', null, null, {toastError: false}).then(() => {
|
|
32
32
|
localStorage.clear()
|
|
33
33
|
this.setState({alertVisible: true})
|
|
34
|
-
}).catch(
|
|
34
|
+
}).catch(e => {
|
|
35
35
|
console.error('[HeaderRight] 退出登录失败:', e);
|
|
36
36
|
this.setState({confirmVisible: true})
|
|
37
37
|
})
|
|
@@ -51,7 +51,8 @@ export class HeaderRight extends React.Component {
|
|
|
51
51
|
|
|
52
52
|
if (this.state.isMobileDevice) {
|
|
53
53
|
return <div className='header-right'>
|
|
54
|
-
<
|
|
54
|
+
<button type="button" style={{background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer'}}
|
|
55
|
+
onClick={this.logout}>退出</button>
|
|
55
56
|
</div>
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -68,10 +69,10 @@ export class HeaderRight extends React.Component {
|
|
|
68
69
|
|
|
69
70
|
return <div className='header-right'>
|
|
70
71
|
{headerArticles.map(a => (
|
|
71
|
-
<
|
|
72
|
-
|
|
72
|
+
<button key={a.code} type="button" className='item'
|
|
73
|
+
onClick={() => this.openArticle(a.code, a.title)}>
|
|
73
74
|
{a.title}
|
|
74
|
-
</
|
|
75
|
+
</button>
|
|
75
76
|
))}
|
|
76
77
|
|
|
77
78
|
<Dropdown menu={{
|
|
@@ -34,7 +34,7 @@ export class TabLayout extends React.Component {
|
|
|
34
34
|
const pathname = qIndex === -1 ? url : url.substring(0, qIndex);
|
|
35
35
|
const search = qIndex === -1 ? '' : url.substring(qIndex);
|
|
36
36
|
this.setState(prev => ({
|
|
37
|
-
tabs: prev.tabs.
|
|
37
|
+
tabs: prev.tabs.some(t => t.key === url) ? prev.tabs
|
|
38
38
|
: [...prev.tabs, {key: url, label: getLabel(pathname, search, this.props.pathMenuMap), refreshKey: 0}],
|
|
39
39
|
activeKey: url,
|
|
40
40
|
}));
|
|
@@ -42,7 +42,7 @@ export class TabLayout extends React.Component {
|
|
|
42
42
|
|
|
43
43
|
closeTab = (key) => {
|
|
44
44
|
this.setState(prev => {
|
|
45
|
-
if (!prev.tabs.
|
|
45
|
+
if (!prev.tabs.some(t => t.key === key)) return null;
|
|
46
46
|
const next = prev.tabs.filter(t => t.key !== key);
|
|
47
47
|
if (next.length === 0) {
|
|
48
48
|
setTimeout(() => history.push('/'), 0);
|