@jiangood/open-admin 3.1.11 → 3.1.12
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 +3 -3
- package/src/framework/GlobalData.ts +3 -1
- package/src/framework/biz/OrgTree.tsx +1 -1
- package/src/framework/biz/RoleTree.tsx +1 -1
- package/src/framework/biz/UserPicker.tsx +4 -4
- package/src/framework/biz/index.tsx +4 -4
- package/src/framework/components/LinkButton/index.tsx +4 -0
- package/src/framework/components/OrgSwitcher/index.tsx +2 -2
- package/src/framework/components/Page/index.tsx +6 -14
- package/src/framework/components/ProTable/index.tsx +3 -1
- package/src/framework/components/ThemeSettings/index.tsx +5 -38
- package/src/framework/config/index.ts +12 -0
- package/src/framework/config/layoutConfig.ts +47 -8
- package/src/framework/config/themeConfig.ts +93 -25
- package/src/framework/config/uiDefaults.ts +26 -0
- package/src/framework/fields/FieldEditor/index.tsx +1 -1
- package/src/framework/fields/FieldUploadFile/index.tsx +1 -1
- package/src/framework/fields/FieldUploadImage/index.tsx +42 -10
- package/src/framework/fields/FieldUserPicker/index.tsx +3 -3
- package/src/framework/fields/orgTree.ts +1 -1
- package/src/framework/router/index.ts +1 -0
- package/src/framework/router/matcher.ts +19 -4
- package/src/framework/utils/AvatarUtils.ts +64 -0
- package/src/framework/utils/HttpClient.ts +5 -5
- package/src/framework/utils/PageUtils.ts +38 -7
- package/src/framework/utils/TitleUtils.ts +38 -0
- package/src/framework/utils/UrlUtils.ts +3 -3
- package/src/framework/utils/index.ts +2 -0
- package/src/layouts/admin/HeaderRight.jsx +62 -16
- package/src/layouts/admin/SinglePageLayout.jsx +13 -2
- package/src/layouts/admin/TabLayout.jsx +38 -19
- package/src/layouts/admin/index.jsx +4 -4
- package/src/layouts/index.tsx +52 -15
- package/src/pages/404.jsx +4 -0
- package/src/pages/article/$code.jsx +5 -1
- package/src/pages/login.jsx +157 -0
- package/src/pages/{public/login.less → login.less} +16 -1
- package/src/pages/public/login.jsx +11 -145
- package/src/pages/standalone/{forceUpdatePwd.jsx → force-update-pwd.jsx} +10 -4
- package/src/pages/system/article/index.jsx +1 -1
- package/src/pages/system/config/index.jsx +46 -20
- package/src/pages/system/file/index.jsx +3 -3
- package/src/pages/system/job/index.jsx +1 -1
- package/src/pages/system/log/index.jsx +1 -1
- package/src/pages/system/online/index.jsx +95 -0
- package/src/pages/system/org/index.jsx +7 -7
- package/src/pages/system/role/index.jsx +8 -8
- package/src/pages/system/role/{rolePerm.jsx → role-perm.jsx} +9 -4
- package/src/pages/system/user/{userPerm.jsx → UserPermModal.jsx} +3 -3
- package/src/pages/system/user/index.jsx +6 -6
- package/src/pages/user-center/AvatarPicker.jsx +76 -0
- package/src/pages/{userCenter/permView.jsx → user-center/PermView.jsx} +48 -25
- package/src/pages/user-center/RecentOps.jsx +36 -0
- package/src/pages/user-center/index.jsx +393 -0
- package/vite-plugin/index.js +64 -6
- package/src/pages/userCenter/index.jsx +0 -93
|
@@ -149,7 +149,7 @@ export class FieldUploadFile extends React.Component<FieldUploadFileProps, Field
|
|
|
149
149
|
const {accept, fileList, maxCount} = this.state;
|
|
150
150
|
|
|
151
151
|
return <Upload
|
|
152
|
-
action={UrlUtils.contextPath('/admin/
|
|
152
|
+
action={UrlUtils.contextPath('/admin/sys-file/upload')}
|
|
153
153
|
data={{isPublic: this.state.isPublic}}
|
|
154
154
|
listType={this.props.listType || 'picture-card'}
|
|
155
155
|
fileList={fileList}
|
|
@@ -16,6 +16,14 @@ export interface FieldUploadImageProps extends FieldProps<string> {
|
|
|
16
16
|
isPublic?: boolean;
|
|
17
17
|
/** 接受的文件类型 */
|
|
18
18
|
accept?: string;
|
|
19
|
+
/** 进入裁切工具时的默认裁切比例,默认 4:3;传 null 表示自由裁切 */
|
|
20
|
+
defaultCropRatio?: Dims | null;
|
|
21
|
+
/** 缩略图与触发按钮形状,圆形用于头像类场景,默认方形 */
|
|
22
|
+
shape?: 'square' | 'circle';
|
|
23
|
+
/** 缩略图与触发按钮的尺寸(px),默认 80 */
|
|
24
|
+
size?: number;
|
|
25
|
+
/** 触发按钮的提示文案,默认不显示提示 */
|
|
26
|
+
triggerTitle?: string;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
interface Dims {
|
|
@@ -167,6 +175,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
|
|
|
167
175
|
constructor(props: FieldUploadImageProps) {
|
|
168
176
|
super(props);
|
|
169
177
|
ObjectUtils.copyPropertyIfPresent(props, this.state);
|
|
178
|
+
if (props.defaultCropRatio !== undefined) this.state.cropRatio = props.defaultCropRatio;
|
|
170
179
|
if (props.value) this.state.objectNames = props.value.split(',');
|
|
171
180
|
}
|
|
172
181
|
|
|
@@ -363,7 +372,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
|
|
|
363
372
|
fd.append('file', this.state.preview.cFile);
|
|
364
373
|
fd.append('thumb', tFile);
|
|
365
374
|
fd.append('isPublic', String(this.state.isPublic));
|
|
366
|
-
const rs = await HttpClient.post('admin/
|
|
375
|
+
const rs = await HttpClient.post('admin/sys-file/upload-image', fd, null, {toastError: false});
|
|
367
376
|
this.setState(
|
|
368
377
|
(prevState) => ({objectNames: [...prevState.objectNames, rs.data.objectName]}),
|
|
369
378
|
() => this.props.onChange?.(this.state.objectNames.join(','))
|
|
@@ -395,6 +404,9 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
|
|
|
395
404
|
|
|
396
405
|
render() {
|
|
397
406
|
const {objectNames, accept, compressWidth, compressSize, cropRatio, cropperReady, fullPreviewUrl, maxCount, modalOpen, originalUrl, preview, tool, uploading} = this.state;
|
|
407
|
+
const {shape = 'square', size = 80, triggerTitle} = this.props;
|
|
408
|
+
const round = shape === 'circle';
|
|
409
|
+
const boxRadius = round ? '50%' : 4;
|
|
398
410
|
|
|
399
411
|
// 画布当前显示的图片
|
|
400
412
|
const canvasImg = preview
|
|
@@ -412,8 +424,8 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
|
|
|
412
424
|
<div>
|
|
413
425
|
{objectNames.map((name) => (
|
|
414
426
|
<div key={name} style={{
|
|
415
|
-
position: 'relative', display: 'inline-block', marginRight: 8, verticalAlign: 'top',
|
|
416
|
-
width:
|
|
427
|
+
position: 'relative', display: 'inline-block', marginRight: round ? 0 : 8, verticalAlign: 'top',
|
|
428
|
+
width: size, height: size, borderRadius: boxRadius, overflow: 'hidden', cursor: 'pointer',
|
|
417
429
|
}}
|
|
418
430
|
onMouseEnter={(e) => {
|
|
419
431
|
const mask = e.currentTarget.querySelector('.oa-field-upload-img-mask') as HTMLElement;
|
|
@@ -432,14 +444,14 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
|
|
|
432
444
|
>
|
|
433
445
|
<img
|
|
434
446
|
src={UrlUtils.contextPath(`/file/${name}?thumb=1`)}
|
|
435
|
-
width={
|
|
436
|
-
height={
|
|
437
|
-
style={{objectFit: 'cover', borderRadius:
|
|
447
|
+
width={size}
|
|
448
|
+
height={size}
|
|
449
|
+
style={{objectFit: 'cover', borderRadius: boxRadius, display: 'block'}}
|
|
438
450
|
alt={name}
|
|
439
451
|
/>
|
|
440
452
|
<div style={{
|
|
441
453
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
442
|
-
background: 'rgba(0,0,0,0.45)', opacity: 0, transition: 'opacity .3s', borderRadius:
|
|
454
|
+
background: 'rgba(0,0,0,0.45)', opacity: 0, transition: 'opacity .3s', borderRadius: boxRadius,
|
|
443
455
|
pointerEvents: 'none',
|
|
444
456
|
}} className="oa-field-upload-img-mask">
|
|
445
457
|
<Space size={12}>
|
|
@@ -462,9 +474,29 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
|
|
|
462
474
|
</div>
|
|
463
475
|
))}
|
|
464
476
|
{objectNames.length < maxCount && (
|
|
465
|
-
|
|
466
|
-
<
|
|
467
|
-
|
|
477
|
+
round ? (
|
|
478
|
+
<Upload accept={accept} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={false}>
|
|
479
|
+
<button type="button"
|
|
480
|
+
title={triggerTitle}
|
|
481
|
+
aria-label={triggerTitle || '选择图片'}
|
|
482
|
+
style={{
|
|
483
|
+
width: size, height: size, padding: 0, boxSizing: 'border-box',
|
|
484
|
+
border: '2px dashed var(--oa-color-border, #d9d9d9)',
|
|
485
|
+
borderRadius: '50%',
|
|
486
|
+
background: 'var(--oa-color-fill-quaternary, #fafafa)',
|
|
487
|
+
color: 'var(--oa-color-text-secondary, rgba(0, 0, 0, 0.65))',
|
|
488
|
+
cursor: 'pointer', lineHeight: 0,
|
|
489
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
490
|
+
fontSize: 18,
|
|
491
|
+
}}>
|
|
492
|
+
<PlusOutlined/>
|
|
493
|
+
</button>
|
|
494
|
+
</Upload>
|
|
495
|
+
) : (
|
|
496
|
+
<Upload accept={accept} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={false}>
|
|
497
|
+
<Button icon={<PlusOutlined/>}>选择图片</Button>
|
|
498
|
+
</Upload>
|
|
499
|
+
)
|
|
468
500
|
)}
|
|
469
501
|
</div>
|
|
470
502
|
|
|
@@ -14,9 +14,9 @@ export interface FieldUserPickerProps extends FieldProps<string[]> {
|
|
|
14
14
|
placeholder?: string;
|
|
15
15
|
/** 弹窗标题 */
|
|
16
16
|
title?: string;
|
|
17
|
-
/** 分页数据源地址,默认 admin/
|
|
17
|
+
/** 分页数据源地址,默认 admin/sys-user/page */
|
|
18
18
|
requestUrl?: string;
|
|
19
|
-
/** 按 id 查询用户详情地址(用于回显),默认 admin/
|
|
19
|
+
/** 按 id 查询用户详情地址(用于回显),默认 admin/sys-user/by-ids */
|
|
20
20
|
usersUrl?: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -52,7 +52,7 @@ export class FieldUserPicker extends React.Component<FieldUserPickerProps, Field
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
getUsersUrl = () => this.props.usersUrl || 'admin/
|
|
55
|
+
getUsersUrl = () => this.props.usersUrl || 'admin/sys-user/by-ids';
|
|
56
56
|
|
|
57
57
|
// 根据 value(ids) 拉取并补全用户详情,按 ids 顺序展示
|
|
58
58
|
syncUsers = () => {
|
|
@@ -4,9 +4,23 @@ function safeDecodeURIComponent(s: string): string {
|
|
|
4
4
|
try { return decodeURIComponent(s); } catch { return s; }
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
/** 页面布局:admin = 后台菜单布局;blank = 仅渲染页面内容 */
|
|
8
|
+
export type PageLayout = 'admin' | 'blank';
|
|
9
|
+
|
|
10
|
+
/** 页面元信息:页面文件通过 export const pageMeta 声明,由 vite 插件写入路由表 */
|
|
11
|
+
export interface PageMeta {
|
|
12
|
+
/** 页面标题:用于页签标题和浏览器标题;未设置时回退到后端菜单名 */
|
|
13
|
+
title?: string;
|
|
14
|
+
/** 是否需要登录,默认 true */
|
|
15
|
+
requiresAuth?: boolean;
|
|
16
|
+
/** 页面布局,默认 admin */
|
|
17
|
+
layout?: PageLayout;
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
export interface RouteDef {
|
|
8
21
|
path: string;
|
|
9
22
|
component: ComponentType<unknown>;
|
|
23
|
+
meta?: PageMeta;
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
interface InternalRoute extends RouteDef {
|
|
@@ -16,6 +30,7 @@ interface InternalRoute extends RouteDef {
|
|
|
16
30
|
export interface MatchResult {
|
|
17
31
|
component: ComponentType<unknown>;
|
|
18
32
|
params: Record<string, string>;
|
|
33
|
+
meta?: PageMeta;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
const routes: InternalRoute[] = [];
|
|
@@ -27,8 +42,8 @@ function compile(path: string): InternalRoute['segments'] {
|
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export function registerRoutes(defs: RouteDef[]): void {
|
|
30
|
-
for (const {path, component} of defs) {
|
|
31
|
-
routes.push({path, component, segments: compile(path)});
|
|
45
|
+
for (const {path, component, meta} of defs) {
|
|
46
|
+
routes.push({path, component, meta, segments: compile(path)});
|
|
32
47
|
}
|
|
33
48
|
}
|
|
34
49
|
|
|
@@ -51,8 +66,8 @@ export function matchRoute(pathname: string): MatchResult | null {
|
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
if (!ok) continue;
|
|
54
|
-
if (!dynamic) return {component: route.component, params};
|
|
55
|
-
if (!best) best = {component: route.component, params};
|
|
69
|
+
if (!dynamic) return {component: route.component, params, meta: route.meta};
|
|
70
|
+
if (!best) best = {component: route.component, params, meta: route.meta};
|
|
56
71
|
}
|
|
57
72
|
return best;
|
|
58
73
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {UrlUtils} from './UrlUtils';
|
|
2
|
+
|
|
3
|
+
/** 内置头像标识前缀,数据库中存 preset:avatar-1 这类值 */
|
|
4
|
+
const PRESET_PREFIX = 'preset:';
|
|
5
|
+
|
|
6
|
+
/** 内置头像文件命名规则,需与后端 AvatarPresets 保持一致 */
|
|
7
|
+
const PRESET_PATTERN = /^preset:avatar-(?:[1-9]|1[0-2])$/;
|
|
8
|
+
|
|
9
|
+
/** 十二生肖头像名称,需与后端 AvatarPresets 保持一致 */
|
|
10
|
+
const ZODIAC_LABELS = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'];
|
|
11
|
+
|
|
12
|
+
export interface AvatarPreset {
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
url: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 默认头像已移除。头像为空时返回空串,由 AntD Avatar 的 icon 兜底显示人形占位。
|
|
20
|
+
*
|
|
21
|
+
* @deprecated 不再提供默认头像图片,保留该导出仅为兼容旧引用
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_AVATAR_URL = '';
|
|
24
|
+
|
|
25
|
+
function presetUrl(key: string): string | null {
|
|
26
|
+
if (!PRESET_PATTERN.test(key)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return UrlUtils.contextPath(`/avatars/${key.substring(PRESET_PREFIX.length)}.svg`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 接口不可用时的兜底列表,与后端 AvatarPresets 保持一致。
|
|
34
|
+
* 正常使用时优先读取 admin/public/avatar-presets 返回的数据。
|
|
35
|
+
*/
|
|
36
|
+
export const FALLBACK_AVATAR_PRESETS: AvatarPreset[] = [
|
|
37
|
+
...ZODIAC_LABELS.map((label, index) => {
|
|
38
|
+
const key = `${PRESET_PREFIX}avatar-${index + 1}`;
|
|
39
|
+
return {
|
|
40
|
+
key,
|
|
41
|
+
label,
|
|
42
|
+
url: presetUrl(key) as string,
|
|
43
|
+
};
|
|
44
|
+
}),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export class AvatarUtils {
|
|
48
|
+
|
|
49
|
+
/** 是否为内置头像标识 */
|
|
50
|
+
static isPreset(avatar?: string | null): boolean {
|
|
51
|
+
return !!avatar && avatar.startsWith(PRESET_PREFIX);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 将数据库中的头像值转换为可访问的图片地址;为空或无效时返回空串,由调用方兜底占位 */
|
|
55
|
+
static resolve(avatar?: string | null): string {
|
|
56
|
+
if (!avatar) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
if (AvatarUtils.isPreset(avatar)) {
|
|
60
|
+
return presetUrl(avatar) || '';
|
|
61
|
+
}
|
|
62
|
+
return UrlUtils.contextPath(`/file/${avatar}?thumb=1`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -21,13 +21,13 @@ import {getMessageApi} from "./MessageApi";
|
|
|
21
21
|
* 用法:
|
|
22
22
|
* ```ts
|
|
23
23
|
* // 提交:成功自动弹后端 message(如"更新成功"),失败自动弹错并 reject(可用 await 感知结果,如 FormModal 提交失败保持弹窗)
|
|
24
|
-
* await HttpClient.post('admin/
|
|
24
|
+
* await HttpClient.post('admin/sys-user/create', values);
|
|
25
25
|
*
|
|
26
26
|
* // 取返回数据与后端提示文案
|
|
27
|
-
* const {data, message} = await HttpClient.post('admin/
|
|
27
|
+
* const {data, message} = await HttpClient.post('admin/sys-user/create', values);
|
|
28
28
|
*
|
|
29
29
|
* // 成功自控提示(关闭自动弹后由业务决定)
|
|
30
|
-
* await HttpClient.post('admin/
|
|
30
|
+
* await HttpClient.post('admin/sys-user/delete', {id}, null, {toastSuccess: false});
|
|
31
31
|
* messageApi.success('删除成功');
|
|
32
32
|
* reload();
|
|
33
33
|
*
|
|
@@ -37,10 +37,10 @@ import {getMessageApi} from "./MessageApi";
|
|
|
37
37
|
* .catch((e) => myHandle(e));
|
|
38
38
|
*
|
|
39
39
|
* // $.ajax 形态
|
|
40
|
-
* HttpClient.ajax({url: 'admin/
|
|
40
|
+
* HttpClient.ajax({url: 'admin/sys-user/page', method: 'GET', params}).then((rs) => render(rs.data));
|
|
41
41
|
*
|
|
42
42
|
* // 下载(返回 Promise<Blob>,成功已触发浏览器保存)
|
|
43
|
-
* HttpClient.download({url: 'admin/
|
|
43
|
+
* HttpClient.download({url: 'admin/sys-user/export', method: 'POST', data: {deptId: 1},
|
|
44
44
|
* onDownloadProgress: ({loaded, total}) => console.log(loaded, total)})
|
|
45
45
|
* .catch(() => {});
|
|
46
46
|
* ```
|
|
@@ -2,15 +2,33 @@ import {history} from '../router';
|
|
|
2
2
|
import {StringUtils} from "./StringUtils";
|
|
3
3
|
import {UrlUtils} from "./UrlUtils";
|
|
4
4
|
import {EventBus} from "./EventBus";
|
|
5
|
+
import {getPageTitle} from "./TitleUtils";
|
|
6
|
+
|
|
7
|
+
/** 登录页路径:公开页面,支持 ?redirect= 回跳 */
|
|
8
|
+
export const LOGIN_PATH = '/login';
|
|
9
|
+
|
|
10
|
+
let labelDeprecatedWarned = false;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @internal
|
|
14
|
+
* `_label` 参数已弃用:首次被使用时提示一次,方便业务项目迁移
|
|
15
|
+
*/
|
|
16
|
+
export function warnDeprecatedPageLabel(): void {
|
|
17
|
+
if (labelDeprecatedWarned) return;
|
|
18
|
+
labelDeprecatedWarned = true;
|
|
19
|
+
console.warn(
|
|
20
|
+
'[open-admin] 检测到已弃用的 _label 参数:该参数已不再生效,请改用页面文件中的 pageMeta.title。'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
5
23
|
|
|
6
24
|
export class PageUtils {
|
|
7
25
|
static redirectToLogin(): void {
|
|
8
|
-
if (PageUtils.currentPathname() ===
|
|
26
|
+
if (PageUtils.currentPathname() === LOGIN_PATH) {
|
|
9
27
|
return;
|
|
10
28
|
}
|
|
11
29
|
const url = PageUtils.currentUrl();
|
|
12
30
|
const encodedUrl = encodeURIComponent(url);
|
|
13
|
-
history.push(
|
|
31
|
+
history.push(`${LOGIN_PATH}?redirect=${encodedUrl}`);
|
|
14
32
|
}
|
|
15
33
|
|
|
16
34
|
static currentParams(): Record<string, string | undefined> {
|
|
@@ -31,16 +49,29 @@ export class PageUtils {
|
|
|
31
49
|
return StringUtils.subAfterLast(path, '/') || undefined;
|
|
32
50
|
}
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 打开页面并切换到对应 Tab
|
|
54
|
+
* @param path 页面路径
|
|
55
|
+
* @param label @deprecated 已不再生效,仅用于提示迁移;页面标题请使用 pageMeta.title
|
|
56
|
+
*/
|
|
57
|
+
static open(path: string, label?: string): void {
|
|
36
58
|
if (label) {
|
|
37
|
-
|
|
59
|
+
warnDeprecatedPageLabel();
|
|
38
60
|
}
|
|
39
|
-
history.push(
|
|
61
|
+
history.push(path);
|
|
40
62
|
}
|
|
41
63
|
|
|
64
|
+
/**
|
|
65
|
+
* @deprecated `_label` 已不再生效,页面标题请使用 pageMeta.title 或 {@link PageUtils.currentTitle}
|
|
66
|
+
*/
|
|
42
67
|
static currentLabel(): string | undefined {
|
|
43
|
-
|
|
68
|
+
warnDeprecatedPageLabel();
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 获取当前页面标题(不含系统标题);没有页面标题时返回 undefined */
|
|
73
|
+
static currentTitle(): string | undefined {
|
|
74
|
+
return getPageTitle();
|
|
44
75
|
}
|
|
45
76
|
|
|
46
77
|
static closeCurrent(): void {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {GlobalData} from '../GlobalData';
|
|
2
|
+
|
|
3
|
+
let currentPageTitle: string | undefined;
|
|
4
|
+
|
|
5
|
+
function normalize(title?: string): string | undefined {
|
|
6
|
+
return typeof title === 'string' && title.trim() ? title.trim() : undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function systemTitle(): string {
|
|
10
|
+
const title = GlobalData.getSiteInfo()?.title;
|
|
11
|
+
return typeof title === 'string' && title.trim() ? title.trim() : 'open-admin';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function renderDocumentTitle(): void {
|
|
15
|
+
const next = currentPageTitle ? `${systemTitle()} | ${currentPageTitle}` : systemTitle();
|
|
16
|
+
if (document.title !== next) {
|
|
17
|
+
document.title = next;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 设置当前页面标题(不含系统标题);传空只显示系统标题。
|
|
23
|
+
* 浏览器标题格式:`系统标题 | 页面标题`
|
|
24
|
+
*/
|
|
25
|
+
export function setPageTitle(title?: string): void {
|
|
26
|
+
currentPageTitle = normalize(title);
|
|
27
|
+
renderDocumentTitle();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 站点标题变化后重新渲染文档标题 */
|
|
31
|
+
export function refreshDocumentTitle(): void {
|
|
32
|
+
renderDocumentTitle();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 当前页面标题(不含系统标题);未设置时返回 undefined */
|
|
36
|
+
export function getPageTitle(): string | undefined {
|
|
37
|
+
return currentPageTitle;
|
|
38
|
+
}
|
|
@@ -26,16 +26,16 @@ export class UrlUtils {
|
|
|
26
26
|
|
|
27
27
|
static paramsToSearch(params: Record<string, string | number | boolean | null | undefined> | null | undefined): string {
|
|
28
28
|
if (!params) return "";
|
|
29
|
-
const
|
|
29
|
+
const search = new URLSearchParams();
|
|
30
30
|
for (const k in params) {
|
|
31
31
|
if (Object.hasOwn(params, k)) {
|
|
32
32
|
const v = params[k];
|
|
33
33
|
if (v !== null && v !== undefined) {
|
|
34
|
-
|
|
34
|
+
search.append(k, String(v));
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
return
|
|
38
|
+
return search.toString();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
static setParam(url: string, key: string, value: string | number | boolean | null | undefined): string {
|
|
@@ -11,6 +11,8 @@ export * from './ObjectUtils'
|
|
|
11
11
|
export * from './DictUtils'
|
|
12
12
|
export * from './PermUtils'
|
|
13
13
|
export * from './PageUtils'
|
|
14
|
+
export * from './TitleUtils'
|
|
14
15
|
export * from './HttpClient'
|
|
15
16
|
export * from './MessageApi'
|
|
16
17
|
export * from './ModalApi'
|
|
18
|
+
export * from './AvatarUtils'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {Avatar, Dropdown, Modal} from "antd";
|
|
2
|
-
import {BgColorsOutlined} from "@ant-design/icons";
|
|
2
|
+
import {BgColorsOutlined, FileOutlined, MoonOutlined, ProfileOutlined, SunOutlined, UserOutlined} from "@ant-design/icons";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import {HttpClient, PageUtils, GlobalData,
|
|
4
|
+
import {HttpClient, PageUtils, GlobalData, history, EventBus, LOGIN_PATH, ThemeSettings, AvatarUtils, ARTICLE_HEADER_AVATAR_DROPDOWN, ARTICLE_HEADER_RIGHT, getThemeMode, toggleThemeMode, getLayoutMode, toggleLayoutMode} from "../../framework";
|
|
5
5
|
|
|
6
6
|
export class HeaderRight extends React.Component {
|
|
7
7
|
|
|
@@ -13,8 +13,30 @@ export class HeaderRight extends React.Component {
|
|
|
13
13
|
themeSettingsVisible: false,
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
unsubscribeLoginInfo = null;
|
|
17
|
+
unsubscribeTheme = null;
|
|
18
|
+
unsubscribeLayout = null;
|
|
19
|
+
|
|
16
20
|
componentDidMount() {
|
|
17
21
|
this.loadArticles()
|
|
22
|
+
// 个人中心修改头像/资料后刷新顶栏显示
|
|
23
|
+
this.unsubscribeLoginInfo = EventBus.on('loginInfoChange', () => this.setState({}))
|
|
24
|
+
// 明暗切换后刷新太阳/月亮图标(其它入口切换时也能同步)
|
|
25
|
+
this.unsubscribeTheme = EventBus.on('themeChange', () => this.setState({}))
|
|
26
|
+
// 布局切换后刷新图标(其它入口切换时也能同步)
|
|
27
|
+
this.unsubscribeLayout = EventBus.on('layoutChange', () => this.setState({}))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
componentWillUnmount() {
|
|
31
|
+
if (this.unsubscribeLoginInfo) {
|
|
32
|
+
this.unsubscribeLoginInfo()
|
|
33
|
+
}
|
|
34
|
+
if (this.unsubscribeTheme) {
|
|
35
|
+
this.unsubscribeTheme()
|
|
36
|
+
}
|
|
37
|
+
if (this.unsubscribeLayout) {
|
|
38
|
+
this.unsubscribeLayout()
|
|
39
|
+
}
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
loadArticles = () => {
|
|
@@ -36,11 +58,34 @@ export class HeaderRight extends React.Component {
|
|
|
36
58
|
}
|
|
37
59
|
|
|
38
60
|
account = () => {
|
|
39
|
-
PageUtils.open('/
|
|
61
|
+
PageUtils.open('/user-center')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
openArticle = (code) => {
|
|
65
|
+
PageUtils.open('/article/' + code)
|
|
40
66
|
}
|
|
41
67
|
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
renderThemeModeToggle = () => {
|
|
69
|
+
const isDark = getThemeMode() === 'dark';
|
|
70
|
+
const title = isDark ? '切换到浅色模式' : '切换到深色模式';
|
|
71
|
+
return (
|
|
72
|
+
<button type="button" className='item' title={title} aria-label={title}
|
|
73
|
+
onClick={() => toggleThemeMode()}>
|
|
74
|
+
{isDark ? <SunOutlined/> : <MoonOutlined/>}
|
|
75
|
+
</button>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
renderLayoutModeToggle = () => {
|
|
80
|
+
const isTabs = getLayoutMode() === 'tabs';
|
|
81
|
+
const title = isTabs ? '切换为单页模式' : '切换为页签模式';
|
|
82
|
+
const Icon = isTabs ? FileOutlined : ProfileOutlined;
|
|
83
|
+
return (
|
|
84
|
+
<button type="button" className='item' title={title} aria-label={title}
|
|
85
|
+
onClick={() => toggleLayoutMode()}>
|
|
86
|
+
<Icon/>
|
|
87
|
+
</button>
|
|
88
|
+
)
|
|
44
89
|
}
|
|
45
90
|
|
|
46
91
|
renderThemeSettings = () => {
|
|
@@ -61,6 +106,7 @@ export class HeaderRight extends React.Component {
|
|
|
61
106
|
|
|
62
107
|
render() {
|
|
63
108
|
const info = GlobalData.getLoginInfo()
|
|
109
|
+
const avatarSrc = AvatarUtils.resolve(info.avatar)
|
|
64
110
|
const {dropdownArticles, headerArticles} = this.state
|
|
65
111
|
|
|
66
112
|
const articleItems = dropdownArticles.map(a => ({
|
|
@@ -69,7 +115,7 @@ export class HeaderRight extends React.Component {
|
|
|
69
115
|
}))
|
|
70
116
|
|
|
71
117
|
const menuItems = [
|
|
72
|
-
{key: '
|
|
118
|
+
{key: 'user-center', label: '个人中心'},
|
|
73
119
|
...articleItems,
|
|
74
120
|
{key: 'logout', label: '退出登录'},
|
|
75
121
|
]
|
|
@@ -77,31 +123,31 @@ export class HeaderRight extends React.Component {
|
|
|
77
123
|
return <div className='header-right'>
|
|
78
124
|
{headerArticles.map(a => (
|
|
79
125
|
<button key={a.code} type="button" className='item'
|
|
80
|
-
onClick={() => this.openArticle(a.code
|
|
126
|
+
onClick={() => this.openArticle(a.code)}>
|
|
81
127
|
{a.title}
|
|
82
128
|
</button>
|
|
83
129
|
))}
|
|
84
130
|
|
|
131
|
+
{this.renderLayoutModeToggle()}
|
|
132
|
+
|
|
133
|
+
{this.renderThemeModeToggle()}
|
|
134
|
+
|
|
85
135
|
{this.renderThemeSettings()}
|
|
86
136
|
|
|
87
137
|
<Dropdown menu={{
|
|
88
138
|
onClick: ({key}) => {
|
|
89
|
-
if (key === '
|
|
139
|
+
if (key === 'user-center') {
|
|
90
140
|
this.account()
|
|
91
141
|
} else if (key === 'logout') {
|
|
92
142
|
this.logout();
|
|
93
143
|
} else if (key.startsWith('article:')) {
|
|
94
|
-
|
|
95
|
-
const article = dropdownArticles.find(a => a.code === code)
|
|
96
|
-
this.openArticle(code, article ? article.title : code)
|
|
144
|
+
this.openArticle(key.substring(8))
|
|
97
145
|
}
|
|
98
146
|
},
|
|
99
147
|
items: menuItems,
|
|
100
148
|
}}>
|
|
101
149
|
<div className='item' style={{cursor: 'pointer'}}>
|
|
102
|
-
<Avatar size="default"
|
|
103
|
-
{info.name?.charAt(0)}
|
|
104
|
-
</Avatar>
|
|
150
|
+
<Avatar size="default" src={avatarSrc || undefined} icon={<UserOutlined/>}/>
|
|
105
151
|
<span style={{marginLeft: 8}}>{info.name}</span>
|
|
106
152
|
</div>
|
|
107
153
|
</Dropdown>
|
|
@@ -111,7 +157,7 @@ export class HeaderRight extends React.Component {
|
|
|
111
157
|
onOk={() => {
|
|
112
158
|
this.setState({alertVisible: false});
|
|
113
159
|
EventBus.emit('logoutSuccess');
|
|
114
|
-
history.replace(
|
|
160
|
+
history.replace(LOGIN_PATH)
|
|
115
161
|
}}>
|
|
116
162
|
退出登录成功
|
|
117
163
|
</Modal>
|
|
@@ -121,7 +167,7 @@ export class HeaderRight extends React.Component {
|
|
|
121
167
|
this.setState({confirmVisible: false});
|
|
122
168
|
localStorage.clear();
|
|
123
169
|
EventBus.emit('logoutSuccess');
|
|
124
|
-
history.replace(
|
|
170
|
+
history.replace(LOGIN_PATH)
|
|
125
171
|
}}>
|
|
126
172
|
退出登录失败,是否清空缓存
|
|
127
173
|
</Modal>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {history, PageFrame} from "../../framework";
|
|
2
|
+
import {history, PageFrame, matchRoute, setPageTitle} from "../../framework";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* 单页布局:不使用页签,仅渲染当前路由对应页面。
|
|
6
6
|
* 路由变化时按 url 重新挂载 PageFrame,页面状态随之重置。
|
|
7
7
|
*/
|
|
8
8
|
export class SinglePageLayout extends React.Component {
|
|
@@ -14,6 +14,11 @@ export class SinglePageLayout extends React.Component {
|
|
|
14
14
|
this.unlisten = history.listen(({location}) => {
|
|
15
15
|
this.setState({location});
|
|
16
16
|
});
|
|
17
|
+
this.syncDocumentTitle();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
componentDidUpdate() {
|
|
21
|
+
this.syncDocumentTitle();
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
componentWillUnmount() {
|
|
@@ -22,6 +27,12 @@ export class SinglePageLayout extends React.Component {
|
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
syncDocumentTitle() {
|
|
31
|
+
const {pathname} = this.state.location;
|
|
32
|
+
const label = matchRoute(pathname)?.meta?.title || this.props.pathMenuMap?.[pathname]?.name;
|
|
33
|
+
setPageTitle(label);
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
render() {
|
|
26
37
|
const {pathname, search} = this.state.location;
|
|
27
38
|
const url = pathname + search;
|