@jiangood/open-admin 3.1.6 → 3.1.8

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 (60) hide show
  1. package/package.json +2 -5
  2. package/src/framework/biz/PermActions.tsx +1 -1
  3. package/src/framework/biz/UserPicker.less +17 -0
  4. package/src/framework/biz/UserPicker.tsx +296 -0
  5. package/src/framework/biz/index.tsx +2 -0
  6. package/src/framework/biz/pickerUser.ts +19 -0
  7. package/src/framework/components/ContextMenu/index.tsx +11 -8
  8. package/src/framework/components/DownloadModal/index.tsx +7 -7
  9. package/src/framework/components/ErrorBoundary/index.tsx +1 -1
  10. package/src/framework/components/FormModal/index.tsx +4 -4
  11. package/src/framework/components/NamedIcon/index.tsx +1 -1
  12. package/src/framework/components/OrgSwitcher/index.tsx +1 -1
  13. package/src/framework/components/Page/index.tsx +18 -4
  14. package/src/framework/components/PageLoading/index.tsx +2 -2
  15. package/src/framework/components/ProTable/index.less +70 -8
  16. package/src/framework/components/ProTable/index.tsx +63 -16
  17. package/src/framework/components/ThemeSettings/index.tsx +173 -0
  18. package/src/framework/components/index.ts +1 -0
  19. package/src/framework/config/index.ts +44 -2
  20. package/src/framework/config/layoutConfig.ts +52 -0
  21. package/src/framework/config/themeConfig.ts +449 -14
  22. package/src/framework/fields/BaseRemoteSelect/index.tsx +4 -4
  23. package/src/framework/fields/FieldDateRange/index.tsx +1 -1
  24. package/src/framework/fields/FieldDictSelect/index.tsx +1 -1
  25. package/src/framework/fields/FieldEditor/index.tsx +2 -2
  26. package/src/framework/fields/FieldNumberRange/index.tsx +1 -1
  27. package/src/framework/fields/FieldUploadFile/index.tsx +3 -3
  28. package/src/framework/fields/FieldUploadImage/index.tsx +19 -19
  29. package/src/framework/fields/FieldUserPicker/index.tsx +124 -0
  30. package/src/framework/fields/index.ts +1 -0
  31. package/src/framework/utils/HttpClient.ts +15 -4
  32. package/src/framework/utils/MessageApi.ts +16 -3
  33. package/src/framework/utils/UrlUtils.ts +1 -1
  34. package/src/framework/views/ViewText/index.tsx +1 -1
  35. package/src/layouts/admin/AdminTabs.less +11 -11
  36. package/src/layouts/admin/HeaderRight.jsx +23 -12
  37. package/src/layouts/admin/SinglePageLayout.jsx +36 -0
  38. package/src/layouts/admin/TabLayout.jsx +21 -1
  39. package/src/layouts/admin/index.jsx +46 -26
  40. package/src/layouts/admin/index.less +68 -24
  41. package/src/layouts/index.tsx +29 -4
  42. package/src/pages/article/$code.jsx +2 -2
  43. package/src/pages/article/article.less +13 -13
  44. package/src/pages/index.jsx +1 -1
  45. package/src/pages/public/login.jsx +29 -15
  46. package/src/pages/public/login.less +243 -22
  47. package/src/pages/standalone/forceUpdatePwd.jsx +8 -7
  48. package/src/pages/system/article/index.jsx +13 -15
  49. package/src/pages/system/codegen/index.jsx +54 -51
  50. package/src/pages/system/dict/index.jsx +2 -2
  51. package/src/pages/system/file/index.jsx +3 -3
  52. package/src/pages/system/job/index.jsx +57 -20
  53. package/src/pages/system/log/index.jsx +2 -2
  54. package/src/pages/system/org/index.jsx +5 -5
  55. package/src/pages/system/role/index.jsx +28 -55
  56. package/src/pages/system/role/rolePerm.jsx +1 -1
  57. package/src/pages/system/user/index.jsx +7 -11
  58. package/src/pages/system/user/userPerm.jsx +5 -5
  59. package/src/pages/userCenter/permView.jsx +2 -2
  60. package/src/style/global.less +6 -6
@@ -1,10 +1,10 @@
1
1
  import React from "react";
2
- import {Button, Divider, Modal, Radio, Select, Space, Upload, message} from "antd";
2
+ import {Button, Divider, Modal, Radio, Select, Space, Upload} from "antd";
3
3
  import {DeleteOutlined, EyeOutlined, PlusOutlined} from "@ant-design/icons";
4
4
  import Compressor from "compressorjs";
5
5
  import Cropper from "cropperjs";
6
6
  import "cropperjs/dist/cropper.css";
7
- import {HttpClient, ObjectUtils, UrlUtils} from "../../utils";
7
+ import {HttpClient, getMessageApi, ObjectUtils, UrlUtils} from "../../utils";
8
8
  import type {FieldProps} from '../types';
9
9
 
10
10
  export interface FieldUploadImageProps extends FieldProps<string> {
@@ -91,7 +91,7 @@ function readDims(url: string): Promise<Dims> {
91
91
 
92
92
  function compressToFile(source: Blob, options: {maxWidth?: number; maxHeight?: number; width?: number; height?: number; quality?: number}): Promise<File> {
93
93
  return new Promise((resolve, reject) => {
94
- new Compressor(source, { // NOSONAR (typescript:S1848) — compressorjs 通过构造函数副作用异步压缩,回调内 resolve/reject,实例无需保留
94
+ new Compressor(source, {
95
95
  maxWidth: options.maxWidth,
96
96
  maxHeight: options.maxHeight,
97
97
  width: options.width,
@@ -139,9 +139,9 @@ async function compressToTarget(source: Blob, maxWidth: number | undefined, targ
139
139
  return best;
140
140
  }
141
141
 
142
- export class FieldUploadImage extends React.Component<FieldUploadImageProps, FieldUploadImageState> { // NOSONAR: state 构造器中按 props 初始化,无法全部 readonly
142
+ export class FieldUploadImage extends React.Component<FieldUploadImageProps, FieldUploadImageState> {
143
143
 
144
- state: FieldUploadImageState = { // NOSONAR: 构造器中按 props 初始化,无法 readonly
144
+ state: FieldUploadImageState = {
145
145
  // 传入的参数
146
146
  maxCount: 1,
147
147
  thumbWidth: 300,
@@ -170,7 +170,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
170
170
  if (props.value) this.state.objectNames = props.value.split(',');
171
171
  }
172
172
 
173
- componentDidUpdate(prevProps: FieldUploadImageProps, prevState: FieldUploadImageState) { // NOSONAR: 顺序同步多个 props 到 state,拆分反而更难读
173
+ componentDidUpdate(prevProps: FieldUploadImageProps, prevState: FieldUploadImageState) {
174
174
  const next: Partial<FieldUploadImageState> = {};
175
175
  if (this.props.maxCount !== prevProps.maxCount) next.maxCount = this.props.maxCount;
176
176
  if (this.props.thumbWidth !== prevProps.thumbWidth) next.thumbWidth = this.props.thumbWidth;
@@ -290,19 +290,19 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
290
290
  this.revokePreviewUrls();
291
291
  this.previewUrlsRef = [cUrl];
292
292
  this.setState({preview: {cUrl, cFile, cSize: cFile.size, cdims}, tool: undefined});
293
- message.success(`已压缩:${cdims.width} x ${cdims.height} / ${formatSize(cFile.size)}`);
293
+ getMessageApi().success(`已压缩:${cdims.width} x ${cdims.height} / ${formatSize(cFile.size)}`);
294
294
  } catch {
295
- message.error('压缩失败');
295
+ getMessageApi().error('压缩失败');
296
296
  }
297
297
  };
298
298
 
299
299
  private handleBeforeUpload = async (file: File) => {
300
300
  if (this.state.objectNames.length >= this.state.maxCount) {
301
- message.warning('已达到最大上传数量');
301
+ getMessageApi().warning('已达到最大上传数量');
302
302
  return Upload.LIST_IGNORE;
303
303
  }
304
304
  if (!file.type.startsWith('image/')) {
305
- message.error('请选择图片文件');
305
+ getMessageApi().error('请选择图片文件');
306
306
  return Upload.LIST_IGNORE;
307
307
  }
308
308
 
@@ -319,7 +319,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
319
319
  // 默认展示原图,不做压缩处理,用户可点击「压缩」按钮手动压缩
320
320
  await this.loadOriginalPreview(file, url, dims);
321
321
  } catch {
322
- message.error('读取图片失败');
322
+ getMessageApi().error('读取图片失败');
323
323
  this.closeModal();
324
324
  return;
325
325
  }
@@ -370,7 +370,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
370
370
  );
371
371
  this.closeModal();
372
372
  } catch (e) {
373
- message.error(HttpClient.errToMsg(e));
373
+ getMessageApi().error(HttpClient.errToMsg(e));
374
374
  } finally {
375
375
  this.setState({uploading: false});
376
376
  }
@@ -390,7 +390,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
390
390
  if (canvasImg?.url) {
391
391
  return <img src={canvasImg.url} style={{maxWidth: '100%', maxHeight: '100%'}} alt="预览"/>;
392
392
  }
393
- return <div style={{color: '#999'}}>生成中...</div>;
393
+ return <div style={{color: 'var(--oa-color-text-tertiary, #999)'}}>生成中...</div>;
394
394
  };
395
395
 
396
396
  render() {
@@ -487,14 +487,14 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
487
487
  <div style={{flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12}}>
488
488
  <div style={{
489
489
  flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
490
- background: '#f5f5f5', borderRadius: 8, border: '1px dashed #d9d9d9', position: 'relative', overflow: 'hidden',
490
+ background: 'var(--oa-color-fill, #f5f5f5)', borderRadius: 8, border: '1px dashed var(--oa-color-border, #d9d9d9)', position: 'relative', overflow: 'hidden',
491
491
  }}>
492
492
  {this.renderCanvas(preview, canvasImg, originalUrl)}
493
493
  </div>
494
494
  </div>
495
495
 
496
496
  {/* 右侧属性栏 */}
497
- <div style={{width: 240, flexShrink: 0, borderLeft: '1px solid #f0f0f0', paddingLeft: 16, display: 'flex', flexDirection: 'column', overflow: 'hidden'}}>
497
+ <div style={{width: 240, flexShrink: 0, borderLeft: '1px solid var(--oa-color-border, #f0f0f0)', paddingLeft: 16, display: 'flex', flexDirection: 'column', overflow: 'hidden'}}>
498
498
  <div style={{flex: 1, overflowY: 'auto'}}>
499
499
  {/* 工具栏(横排) */}
500
500
  {tool !== 'crop' && (
@@ -520,14 +520,14 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
520
520
  >
521
521
  {CROP_RATIOS.map((r) => (
522
522
  <Radio key={r.ratio ? `${r.ratio.width}:${r.ratio.height}` : 'free'} value={r.ratio ? `${r.ratio.width}:${r.ratio.height}` : 'free'}>
523
- {r.label}{r.desc ? <span style={{color: '#999', fontSize: 12}}>({r.desc})</span> : null}
523
+ {r.label}{r.desc ? <span style={{color: 'var(--oa-color-text-tertiary, #999)', fontSize: 12}}>({r.desc})</span> : null}
524
524
  </Radio>
525
525
  ))}
526
526
  </Radio.Group>
527
527
  ) : (
528
528
  <>
529
529
  {canvasImg && (
530
- <div style={{color: '#666'}}>
530
+ <div style={{color: 'var(--oa-color-text-secondary, #666)'}}>
531
531
  <div>尺寸:{canvasImg.dims ? `${canvasImg.dims.width} x ${canvasImg.dims.height}` : '--'}</div>
532
532
  <div>体积:{formatSize(canvasImg.size)}</div>
533
533
  </div>
@@ -537,7 +537,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
537
537
  <Divider style={{margin: '12px 0'}}/>
538
538
  <div style={{fontWeight: 600, marginBottom: 8}}>压缩处理</div>
539
539
  <div style={{marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8}}>
540
- <span style={{color: '#666', flexShrink: 0}}>最大宽度</span>
540
+ <span style={{color: 'var(--oa-color-text-secondary, #666)', flexShrink: 0}}>最大宽度</span>
541
541
  <Select
542
542
  value={compressWidth}
543
543
  onChange={(v) => this.setState({compressWidth: v})}
@@ -555,7 +555,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
555
555
  />
556
556
  </div>
557
557
  <div style={{marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8}}>
558
- <span style={{color: '#666', flexShrink: 0}}>最大体积</span>
558
+ <span style={{color: 'var(--oa-color-text-secondary, #666)', flexShrink: 0}}>最大体积</span>
559
559
  <Select
560
560
  value={compressSize}
561
561
  onChange={(v) => this.setState({compressSize: v})}
@@ -0,0 +1,124 @@
1
+ import React from 'react';
2
+ import {Tag} from 'antd';
3
+ import {HttpClient} from '../../utils';
4
+ import type {FieldProps} from '../types';
5
+ import {formatUserLabel, type PickerUser} from '../../biz/pickerUser';
6
+ import {UserPicker} from '../../biz/UserPicker';
7
+
8
+ export interface FieldUserPickerProps extends FieldProps<string[]> {
9
+ /** 是否多选,默认 true */
10
+ multiple?: boolean;
11
+ /** 禁用 */
12
+ disabled?: boolean;
13
+ /** 空值提示,默认 请选择用户 */
14
+ placeholder?: string;
15
+ /** 弹窗标题 */
16
+ title?: string;
17
+ /** 分页数据源地址,默认 admin/sysUser/page */
18
+ requestUrl?: string;
19
+ /** 按 id 查询用户详情地址(用于回显),默认 admin/sysUser/by-ids */
20
+ usersUrl?: string;
21
+ }
22
+
23
+ interface FieldUserPickerState {
24
+ open: boolean;
25
+ users: PickerUser[];
26
+ }
27
+
28
+ /**
29
+ * 选人表单组件
30
+ *
31
+ * 展示已选用户标签并提供弹窗选择,遵循字段组件约定:value + onChange(value 为用户 id 数组)。
32
+ */
33
+ export class FieldUserPicker extends React.Component<FieldUserPickerProps, FieldUserPickerState> {
34
+
35
+ static readonly defaultProps = {
36
+ multiple: true,
37
+ placeholder: '请选择用户',
38
+ };
39
+
40
+ state: FieldUserPickerState = {
41
+ open: false,
42
+ users: [],
43
+ };
44
+
45
+ componentDidMount() {
46
+ this.syncUsers();
47
+ }
48
+
49
+ componentDidUpdate(prevProps: FieldUserPickerProps) {
50
+ if (prevProps.value !== this.props.value) {
51
+ this.syncUsers();
52
+ }
53
+ }
54
+
55
+ getUsersUrl = () => this.props.usersUrl || 'admin/sysUser/by-ids';
56
+
57
+ // 根据 value(ids) 拉取并补全用户详情,按 ids 顺序展示
58
+ syncUsers = () => {
59
+ const ids = this.props.value || [];
60
+ const known = new Map(this.state.users.map(user => [user.id, user]));
61
+ const missing = ids.filter(id => !known.has(id));
62
+ if (missing.length === 0) {
63
+ this.setState({users: ids.map(id => known.get(id)).filter(Boolean) as PickerUser[]});
64
+ return;
65
+ }
66
+ HttpClient.get<PickerUser[]>(this.getUsersUrl(), {ids}, {toastError: false}).then(rs => {
67
+ const merged = new Map(this.state.users.map(user => [user.id, user]));
68
+ (rs.data || []).forEach(user => merged.set(user.id, user));
69
+ const currentIds = this.props.value || [];
70
+ this.setState({users: currentIds.map(id => merged.get(id)).filter(Boolean) as PickerUser[]});
71
+ });
72
+ };
73
+
74
+ remove = (id: string) => {
75
+ this.props.onChange?.((this.props.value || []).filter(value => value !== id));
76
+ };
77
+
78
+ handleOk = (ids: string[], users: PickerUser[]) => {
79
+ this.setState({open: false, users});
80
+ this.props.onChange?.(ids);
81
+ };
82
+
83
+ render() {
84
+ const {disabled, placeholder, multiple, title, requestUrl, usersUrl, value} = this.props;
85
+ const {open, users} = this.state;
86
+ return <>
87
+ <div style={{
88
+ minHeight: 32,
89
+ display: 'flex',
90
+ flexWrap: 'wrap',
91
+ alignItems: 'center',
92
+ gap: 4,
93
+ padding: '4px 8px',
94
+ border: '1px solid var(--oa-color-border, #d9d9d9)',
95
+ borderRadius: 'var(--oa-border-radius, 6px)',
96
+ background: disabled ? 'var(--oa-color-fill, #f5f5f5)' : 'var(--oa-color-bg-container, #fff)',
97
+ cursor: disabled ? 'not-allowed' : undefined,
98
+ }}>
99
+ {users.length === 0 && <span style={{color: 'var(--oa-color-text-tertiary, #999)'}}>{placeholder}</span>}
100
+ {users.map(user => (
101
+ <Tag key={user.id} closable={!disabled} onClose={(e) => {
102
+ e.preventDefault();
103
+ this.remove(user.id);
104
+ }}>
105
+ {formatUserLabel(user)}
106
+ </Tag>
107
+ ))}
108
+ {!disabled && (
109
+ <a style={{marginLeft: users.length ? 'auto' : 0}} onClick={() => this.setState({open: true})}>选择</a>
110
+ )}
111
+ </div>
112
+ <UserPicker
113
+ open={open}
114
+ title={title}
115
+ multiple={multiple}
116
+ value={value}
117
+ requestUrl={requestUrl}
118
+ usersUrl={usersUrl}
119
+ onOk={this.handleOk}
120
+ onCancel={() => this.setState({open: false})}
121
+ />
122
+ </>;
123
+ }
124
+ }
@@ -14,6 +14,7 @@ export * from './FieldTable'
14
14
  export * from './FieldTableSelect'
15
15
  export * from './FieldSysOrgTreeSelect'
16
16
  export * from './FieldSysOrgTree'
17
+ export * from './FieldUserPicker'
17
18
  export * from './FieldPercent'
18
19
  export * from './FieldUploadFile';
19
20
  export * from './FieldUploadImage';
@@ -1,8 +1,8 @@
1
1
  import axios from "axios";
2
2
  import type {AxiosProgressEvent, AxiosRequestConfig} from "axios";
3
3
  import qs from 'qs';
4
- import {message as messageApi} from "antd";
5
4
  import {EventBus} from "./EventBus";
5
+ import {getMessageApi} from "./MessageApi";
6
6
 
7
7
  /**
8
8
  * 单一 Promise 风格 HTTP 工具类(所有请求方法均返回 Promise,无 success/error 回调)
@@ -224,6 +224,11 @@ export class HttpClient {
224
224
  return url.startsWith('admin') ? '/' + url : url;
225
225
  }
226
226
 
227
+ /** 判断是否为普通对象(用于决定是否附加 X-Body-Fields) */
228
+ private static isPlainObject(data: unknown): data is Record<string, unknown> {
229
+ return data !== null && typeof data === 'object' && !Array.isArray(data);
230
+ }
231
+
227
232
  private static coreRequest<T = unknown>(settings: AjaxSettings): Promise<AjaxBody<T>> {
228
233
  const {url, method, params, data, headers, toastError = true, toastSuccess = true} = settings;
229
234
  const config: AxiosRequestConfig = {url: HttpClient.prefixUrl(url), method, params, data, headers};
@@ -231,6 +236,12 @@ export class HttpClient {
231
236
  // FormData 请求交由浏览器自动生成带 boundary 的 multipart Content-Type
232
237
  if (typeof FormData !== 'undefined' && data instanceof FormData) {
233
238
  config.headers = {...config.headers, 'Content-Type': undefined};
239
+ } else if (method === 'POST' && HttpClient.isPlainObject(data)) {
240
+ // 告知后端本次 JSON body 的顶层字段;值为 undefined 的键不会被 JSON.stringify 序列化,需排除
241
+ const bodyFields = Object.keys(data)
242
+ .filter((key) => data[key] !== undefined)
243
+ .join(',');
244
+ config.headers = {...config.headers, 'X-Body-Fields': bodyFields};
234
245
  }
235
246
 
236
247
  const promise = new Promise<AjaxBody<T>>((resolve, reject) => {
@@ -249,7 +260,7 @@ export class HttpClient {
249
260
  return;
250
261
  }
251
262
  if (toastSuccess && message) {
252
- messageApi.success(message);
263
+ getMessageApi().success(message);
253
264
  }
254
265
  resolve(body);
255
266
  }).catch((e: unknown) => {
@@ -268,7 +279,7 @@ export class HttpClient {
268
279
  EventBus.emit('loginExpired');
269
280
  const err: AjaxError = {code: 401, message: '登录过期'};
270
281
  if (toastError) {
271
- messageApi.error('登录过期');
282
+ getMessageApi().error('登录过期');
272
283
  }
273
284
  reject(err);
274
285
  return;
@@ -276,7 +287,7 @@ export class HttpClient {
276
287
  const err = HttpClient.toAjaxError(e);
277
288
  if (toastError) {
278
289
  console.warn(`[HttpClient] ${label}: ${url}`, err.message);
279
- messageApi.error(err.message);
290
+ getMessageApi().error(err.message);
280
291
  }
281
292
  reject(err);
282
293
  }
@@ -1,5 +1,18 @@
1
- import {message} from 'antd';
1
+ import {message as staticMessage} from 'antd';
2
+ import type {MessageInstance} from 'antd/es/message/interface';
2
3
 
3
- export function getMessageApi() {
4
- return message;
4
+ /**
5
+ * message 实例持有者。
6
+ * 静态 message 无法消费 ConfigProvider 主题(暗色模式下仍是浅色),
7
+ * 由布局内的 MessageBridge 通过 App.useApp() 注入跟随主题的实例。
8
+ */
9
+ let messageInstance: MessageInstance = staticMessage;
10
+
11
+ /** 注入 App 上下文中的 message 实例;不传则恢复为静态 message */
12
+ export function setMessageApi(instance?: MessageInstance) {
13
+ messageInstance = instance ?? staticMessage;
14
+ }
15
+
16
+ export function getMessageApi(): MessageInstance {
17
+ return messageInstance;
5
18
  }
@@ -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; // NOSONAR: 需去除尾部斜杠,正则已最简
70
+ return base.replace(/\/+$/, '') + path;
71
71
  }
72
72
  }
@@ -31,7 +31,7 @@ export class ViewText extends React.Component {
31
31
  style={{
32
32
  background: 'none',
33
33
  border: 'none',
34
- borderBottom: '1px dashed #d9d9d9',
34
+ borderBottom: '1px dashed var(--oa-color-border, #d9d9d9)',
35
35
  padding: 0,
36
36
  font: 'inherit',
37
37
  cursor: 'pointer',
@@ -4,8 +4,8 @@
4
4
  overflow-x: auto;
5
5
  overflow-y: hidden;
6
6
  flex-shrink: 0;
7
- border-bottom: 1px solid #e8e8e8;
8
- background: #fff;
7
+ border-bottom: 1px solid var(--oa-color-border, #e8e8e8);
8
+ background: var(--oa-color-bg-container, #fff);
9
9
 
10
10
  &::-webkit-scrollbar {
11
11
  height: 0;
@@ -16,14 +16,14 @@
16
16
  align-items: center;
17
17
  gap: 4px;
18
18
  padding: 0 12px;
19
- height: 32px;
20
- line-height: 32px;
19
+ height: var(--oa-control-height, 32px);
20
+ line-height: var(--oa-control-height, 32px);
21
21
  font-size: 13px;
22
22
  cursor: pointer;
23
- border-right: 1px solid #e8e8e8;
23
+ border-right: 1px solid var(--oa-color-border, #e8e8e8);
24
24
  white-space: nowrap;
25
- color: rgba(0, 0, 0, 0.65);
26
- background: #fafafa;
25
+ color: var(--oa-color-text-secondary, rgba(0, 0, 0, 0.65));
26
+ background: var(--oa-color-fill, #fafafa);
27
27
  transition: color 0.2s, background 0.2s;
28
28
  position: relative;
29
29
  flex-shrink: 0;
@@ -34,7 +34,7 @@
34
34
 
35
35
  &.active {
36
36
  color: var(--primary-color);
37
- background: #fff;
37
+ background: var(--oa-color-bg-container, #fff);
38
38
  font-weight: 500;
39
39
  }
40
40
 
@@ -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); /* NOSONAR: 次要文本颜色,设计如此 */
55
+ color: var(--oa-color-text-tertiary, rgba(0, 0, 0, 0.45));
56
56
 
57
57
  &:hover {
58
- background: rgba(0, 0, 0, 0.08);
59
- color: rgba(0, 0, 0, 0.88); /* NOSONAR: 悬停态主文本色,对比度设计如此 */
58
+ background: var(--oa-color-fill, rgba(0, 0, 0, 0.08));
59
+ color: var(--oa-color-text, rgba(0, 0, 0, 0.88));
60
60
  }
61
61
  }
62
62
  }
@@ -1,21 +1,19 @@
1
1
  import {Avatar, Dropdown, Modal} from "antd";
2
+ import {BgColorsOutlined} from "@ant-design/icons";
2
3
  import React from "react";
3
- import {DeviceUtils, HttpClient, PageUtils, GlobalData, getToken, history, EventBus, ARTICLE_HEADER_AVATAR_DROPDOWN, ARTICLE_HEADER_RIGHT} from "../../framework";
4
+ import {HttpClient, PageUtils, GlobalData, getToken, history, EventBus, ThemeSettings, ARTICLE_HEADER_AVATAR_DROPDOWN, ARTICLE_HEADER_RIGHT} from "../../framework";
4
5
 
5
6
  export class HeaderRight extends React.Component {
6
7
 
7
8
  state = {
8
- isMobileDevice: false,
9
9
  dropdownArticles: [],
10
10
  headerArticles: [],
11
11
  alertVisible: false,
12
12
  confirmVisible: false,
13
+ themeSettingsVisible: false,
13
14
  };
14
15
 
15
16
  componentDidMount() {
16
- if (DeviceUtils.isMobileDevice()) {
17
- this.setState({isMobileDevice: true})
18
- }
19
17
  this.loadArticles()
20
18
  }
21
19
 
@@ -45,17 +43,26 @@ export class HeaderRight extends React.Component {
45
43
  PageUtils.open('/article/' + code, title)
46
44
  }
47
45
 
46
+ renderThemeSettings = () => {
47
+ return (
48
+ <button type="button" className='item' title="界面设置" aria-label="界面设置"
49
+ onClick={() => this.setState({themeSettingsVisible: true})}>
50
+ <BgColorsOutlined/>
51
+ </button>
52
+ )
53
+ }
54
+
55
+ renderThemeSettingsDrawer = () => {
56
+ return (
57
+ <ThemeSettings open={this.state.themeSettingsVisible}
58
+ onClose={() => this.setState({themeSettingsVisible: false})}/>
59
+ )
60
+ }
61
+
48
62
  render() {
49
63
  const info = GlobalData.getLoginInfo()
50
64
  const {dropdownArticles, headerArticles} = this.state
51
65
 
52
- if (this.state.isMobileDevice) {
53
- return <div className='header-right'>
54
- <button type="button" style={{background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer'}}
55
- onClick={this.logout}>退出</button>
56
- </div>
57
- }
58
-
59
66
  const articleItems = dropdownArticles.map(a => ({
60
67
  key: 'article:' + a.code,
61
68
  label: a.title,
@@ -75,6 +82,8 @@ export class HeaderRight extends React.Component {
75
82
  </button>
76
83
  ))}
77
84
 
85
+ {this.renderThemeSettings()}
86
+
78
87
  <Dropdown menu={{
79
88
  onClick: ({key}) => {
80
89
  if (key === 'userCenter') {
@@ -116,6 +125,8 @@ export class HeaderRight extends React.Component {
116
125
  }}>
117
126
  退出登录失败,是否清空缓存
118
127
  </Modal>
128
+
129
+ {this.renderThemeSettingsDrawer()}
119
130
  </div>
120
131
  }
121
132
  }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import {history, PageFrame} from "../../framework";
3
+
4
+ /**
5
+ * 单页布局:不使用标签页,仅渲染当前路由对应页面。
6
+ * 路由变化时按 url 重新挂载 PageFrame,页面状态随之重置。
7
+ */
8
+ export class SinglePageLayout extends React.Component {
9
+ state = {location: history.location};
10
+
11
+ unlisten = null;
12
+
13
+ componentDidMount() {
14
+ this.unlisten = history.listen(({location}) => {
15
+ this.setState({location});
16
+ });
17
+ }
18
+
19
+ componentWillUnmount() {
20
+ if (this.unlisten) {
21
+ this.unlisten();
22
+ }
23
+ }
24
+
25
+ render() {
26
+ const {pathname, search} = this.state.location;
27
+ const url = pathname + search;
28
+ return (
29
+ <div style={{height: '100%'}}>
30
+ <PageFrame key={url} url={url} show/>
31
+ </div>
32
+ );
33
+ }
34
+ }
35
+
36
+ export default SinglePageLayout;
@@ -83,8 +83,28 @@ export class TabLayout extends React.Component {
83
83
  this.unsubscribeClosePage = EventBus.on('closePage', this.handleClosePageEvent);
84
84
  }
85
85
 
86
- componentDidUpdate() {
86
+ componentDidUpdate(prevProps) {
87
87
  this.activeKey = this.state.activeKey;
88
+ // 菜单信息晚于 Tab 创建时才加载完成:路径/菜单映射变化后回填「未命名」Tab 标题
89
+ if (this.props.pathMenuMap !== prevProps.pathMenuMap) {
90
+ this.refreshTabLabels();
91
+ }
92
+ }
93
+
94
+ refreshTabLabels() {
95
+ this.setState(prev => {
96
+ let changed = false;
97
+ const tabs = prev.tabs.map(t => {
98
+ const qIndex = t.key.indexOf('?');
99
+ const pathname = qIndex === -1 ? t.key : t.key.substring(0, qIndex);
100
+ const search = qIndex === -1 ? '' : t.key.substring(qIndex);
101
+ const label = getLabel(pathname, search, this.props.pathMenuMap);
102
+ if (label === t.label) return t;
103
+ changed = true;
104
+ return {...t, label};
105
+ });
106
+ return changed ? {tabs} : null;
107
+ });
88
108
  }
89
109
 
90
110
  componentWillUnmount() {