@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.
Files changed (56) hide show
  1. package/package.json +3 -3
  2. package/src/framework/GlobalData.ts +3 -1
  3. package/src/framework/biz/OrgTree.tsx +1 -1
  4. package/src/framework/biz/RoleTree.tsx +1 -1
  5. package/src/framework/biz/UserPicker.tsx +4 -4
  6. package/src/framework/biz/index.tsx +4 -4
  7. package/src/framework/components/LinkButton/index.tsx +4 -0
  8. package/src/framework/components/OrgSwitcher/index.tsx +2 -2
  9. package/src/framework/components/Page/index.tsx +6 -14
  10. package/src/framework/components/ProTable/index.tsx +3 -1
  11. package/src/framework/components/ThemeSettings/index.tsx +5 -38
  12. package/src/framework/config/index.ts +12 -0
  13. package/src/framework/config/layoutConfig.ts +47 -8
  14. package/src/framework/config/themeConfig.ts +93 -25
  15. package/src/framework/config/uiDefaults.ts +26 -0
  16. package/src/framework/fields/FieldEditor/index.tsx +1 -1
  17. package/src/framework/fields/FieldUploadFile/index.tsx +1 -1
  18. package/src/framework/fields/FieldUploadImage/index.tsx +42 -10
  19. package/src/framework/fields/FieldUserPicker/index.tsx +3 -3
  20. package/src/framework/fields/orgTree.ts +1 -1
  21. package/src/framework/router/index.ts +1 -0
  22. package/src/framework/router/matcher.ts +19 -4
  23. package/src/framework/utils/AvatarUtils.ts +64 -0
  24. package/src/framework/utils/HttpClient.ts +5 -5
  25. package/src/framework/utils/PageUtils.ts +38 -7
  26. package/src/framework/utils/TitleUtils.ts +38 -0
  27. package/src/framework/utils/UrlUtils.ts +3 -3
  28. package/src/framework/utils/index.ts +2 -0
  29. package/src/layouts/admin/HeaderRight.jsx +62 -16
  30. package/src/layouts/admin/SinglePageLayout.jsx +13 -2
  31. package/src/layouts/admin/TabLayout.jsx +38 -19
  32. package/src/layouts/admin/index.jsx +4 -4
  33. package/src/layouts/index.tsx +52 -15
  34. package/src/pages/404.jsx +4 -0
  35. package/src/pages/article/$code.jsx +5 -1
  36. package/src/pages/login.jsx +157 -0
  37. package/src/pages/{public/login.less → login.less} +16 -1
  38. package/src/pages/public/login.jsx +11 -145
  39. package/src/pages/standalone/{forceUpdatePwd.jsx → force-update-pwd.jsx} +10 -4
  40. package/src/pages/system/article/index.jsx +1 -1
  41. package/src/pages/system/config/index.jsx +46 -20
  42. package/src/pages/system/file/index.jsx +3 -3
  43. package/src/pages/system/job/index.jsx +1 -1
  44. package/src/pages/system/log/index.jsx +1 -1
  45. package/src/pages/system/online/index.jsx +95 -0
  46. package/src/pages/system/org/index.jsx +7 -7
  47. package/src/pages/system/role/index.jsx +8 -8
  48. package/src/pages/system/role/{rolePerm.jsx → role-perm.jsx} +9 -4
  49. package/src/pages/system/user/{userPerm.jsx → UserPermModal.jsx} +3 -3
  50. package/src/pages/system/user/index.jsx +6 -6
  51. package/src/pages/user-center/AvatarPicker.jsx +76 -0
  52. package/src/pages/{userCenter/permView.jsx → user-center/PermView.jsx} +48 -25
  53. package/src/pages/user-center/RecentOps.jsx +36 -0
  54. package/src/pages/user-center/index.jsx +393 -0
  55. package/vite-plugin/index.js +64 -6
  56. 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/sysFile/upload')}
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/sysFile/uploadImage', fd, null, {toastError: false});
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: 80, height: 80, borderRadius: 4, overflow: 'hidden', cursor: 'pointer',
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={80}
436
- height={80}
437
- style={{objectFit: 'cover', borderRadius: 4, display: 'block'}}
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: 4,
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
- <Upload accept={accept} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={false}>
466
- <Button icon={<PlusOutlined/>}>选择图片</Button>
467
- </Upload>
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/sysUser/page */
17
+ /** 分页数据源地址,默认 admin/sys-user/page */
18
18
  requestUrl?: string;
19
- /** 按 id 查询用户详情地址(用于回显),默认 admin/sysUser/by-ids */
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/sysUser/by-ids';
55
+ getUsersUrl = () => this.props.usersUrl || 'admin/sys-user/by-ids';
56
56
 
57
57
  // 根据 value(ids) 拉取并补全用户详情,按 ids 顺序展示
58
58
  syncUsers = () => {
@@ -1,3 +1,3 @@
1
1
  export function orgTreeUrl(type: string): string {
2
- return type === 'dept' ? '/admin/sysOrg/dept-tree' : '/admin/sysOrg/unit-tree';
2
+ return type === 'dept' ? '/admin/sys-org/dept-tree' : '/admin/sys-org/unit-tree';
3
3
  }
@@ -1,4 +1,5 @@
1
1
  export {history} from './history';
2
2
  export {registerRoutes, matchRoute} from './matcher';
3
+ export type {PageLayout, PageMeta, RouteDef, MatchResult} from './matcher';
3
4
  export {PageFrame} from './PageFrame';
4
5
  export {Link} from './Link';
@@ -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/sysUser/create', values);
24
+ * await HttpClient.post('admin/sys-user/create', values);
25
25
  *
26
26
  * // 取返回数据与后端提示文案
27
- * const {data, message} = await HttpClient.post('admin/sysUser/create', values);
27
+ * const {data, message} = await HttpClient.post('admin/sys-user/create', values);
28
28
  *
29
29
  * // 成功自控提示(关闭自动弹后由业务决定)
30
- * await HttpClient.post('admin/sysUser/delete', {id}, null, {toastSuccess: false});
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/sysUser/page', method: 'GET', params}).then((rs) => render(rs.data));
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/sysUser/export', method: 'POST', data: {deptId: 1},
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() === '/public/login') {
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(`/public/login?redirect=${encodedUrl}`);
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
- static open(path: string, label = '未命名'): void {
35
- let targetPath = path;
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
- targetPath = UrlUtils.setParam(targetPath, '_label', label);
59
+ warnDeprecatedPageLabel();
38
60
  }
39
- history.push(targetPath);
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
- return this.currentParams()['_label'];
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 buffer: string[] = [];
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
- buffer.push(`${k}=${v}`);
34
+ search.append(k, String(v));
35
35
  }
36
36
  }
37
37
  }
38
- return buffer.join('&');
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, getToken, history, EventBus, ThemeSettings, ARTICLE_HEADER_AVATAR_DROPDOWN, ARTICLE_HEADER_RIGHT} from "../../framework";
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('/userCenter', '个人中心')
61
+ PageUtils.open('/user-center')
62
+ }
63
+
64
+ openArticle = (code) => {
65
+ PageUtils.open('/article/' + code)
40
66
  }
41
67
 
42
- openArticle = (code, title) => {
43
- PageUtils.open('/article/' + code, title)
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: 'userCenter', label: '个人中心'},
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, a.title)}>
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 === 'userCenter') {
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
- const code = key.substring(8)
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" style={{backgroundColor: getToken().colorPrimary}}>
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('/public/login')
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('/public/login')
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;