@nocobase/client-v2 2.3.0-beta.5 → 2.4.0-alpha.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.
Files changed (90) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +11 -2
  4. package/es/components/AttachmentUpload.d.ts +51 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/index.d.ts +4 -1
  8. package/es/index.mjs +129 -118
  9. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  10. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  11. package/es/settings-app/SettingsApplication.d.ts +18 -0
  12. package/es/settings-app/SettingsBrand.d.ts +16 -0
  13. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  14. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  15. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  16. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  17. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  18. package/es/settings-app/SettingsSearch.d.ts +17 -0
  19. package/es/settings-app/SettingsShell.d.ts +10 -0
  20. package/es/settings-app/isSettingsApp.d.ts +18 -0
  21. package/es/settings-app/runtimePaths.d.ts +18 -0
  22. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  23. package/es/settings-app/settingsTheme.d.ts +87 -0
  24. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  25. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  26. package/es/settings-center/groups.d.ts +66 -0
  27. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  28. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  29. package/es/settings-center/utils.d.ts +24 -0
  30. package/es/theme/index.d.ts +1 -0
  31. package/es/theme/itemActive.d.ts +24 -0
  32. package/es/theme/type.d.ts +9 -0
  33. package/lib/index.js +157 -146
  34. package/package.json +7 -7
  35. package/src/__tests__/app.test.tsx +122 -3
  36. package/src/__tests__/authRedirect.test.ts +154 -0
  37. package/src/__tests__/browserChecker.test.ts +172 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +168 -130
  44. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  47. package/src/__tests__/settings-shell.test.tsx +261 -0
  48. package/src/acl/ACLProvider.tsx +53 -3
  49. package/src/acl/aclCheckReadiness.ts +44 -0
  50. package/src/authRedirect.ts +85 -8
  51. package/src/components/AppComponents.tsx +16 -7
  52. package/src/components/AttachmentUpload.tsx +275 -0
  53. package/src/components/index.ts +1 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  55. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  56. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  57. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  58. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  59. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  60. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  61. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  62. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  63. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  64. package/src/index.ts +11 -1
  65. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  66. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  67. package/src/settings-app/SettingsApplication.ts +31 -0
  68. package/src/settings-app/SettingsBrand.tsx +108 -0
  69. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  70. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  71. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  72. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  73. package/src/settings-app/SettingsRouterManager.ts +92 -0
  74. package/src/settings-app/SettingsSearch.tsx +343 -0
  75. package/src/settings-app/SettingsShell.tsx +184 -0
  76. package/src/settings-app/isSettingsApp.ts +21 -0
  77. package/src/settings-app/runtimePaths.ts +103 -0
  78. package/src/settings-app/settingsDocumentPath.ts +66 -0
  79. package/src/settings-app/settingsTheme.ts +369 -0
  80. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  81. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  82. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  83. package/src/settings-center/__tests__/groups.test.ts +25 -0
  84. package/src/settings-center/groups.ts +108 -0
  85. package/src/settings-center/useSettingsGroups.ts +105 -0
  86. package/src/settings-center/useSettingsSearch.ts +173 -0
  87. package/src/settings-center/utils.tsx +50 -1
  88. package/src/theme/index.tsx +1 -0
  89. package/src/theme/itemActive.ts +31 -0
  90. package/src/theme/type.ts +10 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { SearchOutlined } from '@ant-design/icons';
11
+ import { css } from '@emotion/css';
12
+ import { observer } from '@nocobase/flow-engine';
13
+ import { Empty, Input, Modal, theme as antdTheme } from 'antd';
14
+ import type { InputRef } from 'antd';
15
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { useTranslation } from 'react-i18next';
17
+ import { useLocation, useNavigate } from 'react-router-dom';
18
+ import { useACLCheckReady } from '../acl/aclCheckReadiness';
19
+ import { useApp } from '../hooks/useApp';
20
+ import { useSettingsSearch, type SettingsSearchItem } from '../settings-center/useSettingsSearch';
21
+ import { getSettingsHeaderColors } from './settingsTheme';
22
+
23
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
24
+ const SHORTCUT_LABEL = isMac ? '⌘F' : 'Ctrl F';
25
+
26
+ const triggerClassName = css`
27
+ align-items: center;
28
+ border-radius: 6px;
29
+ cursor: pointer;
30
+ display: inline-flex;
31
+ gap: 6px;
32
+ height: 28px;
33
+ padding: 0 10px;
34
+ transition: background 0.2s;
35
+ white-space: nowrap;
36
+ `;
37
+
38
+ const resultListClassName = css`
39
+ max-height: 320px;
40
+ overflow-y: auto;
41
+ margin: 0 -24px -12px;
42
+ padding: 0 12px 12px;
43
+ `;
44
+
45
+ const resultItemClassName = css`
46
+ align-items: baseline;
47
+ border-radius: 6px;
48
+ cursor: pointer;
49
+ display: flex;
50
+ gap: 8px;
51
+ padding: 8px 12px;
52
+ `;
53
+
54
+ /**
55
+ * 设置中心的搜索入口。
56
+ *
57
+ * 顶栏放一个轻量触发器,真正的搜索在弹层里进行;关键词为空时展示最近访问,
58
+ * 这样分组化之后被收进左侧栏的深层页面仍然一步可达。支持 `Cmd/Ctrl + F` 唤起。
59
+ */
60
+ export const SettingsSearch: React.FC = observer(() => {
61
+ const { t } = useTranslation();
62
+ const app = useApp();
63
+ const navigate = useNavigate();
64
+ const location = useLocation();
65
+ const { token } = antdTheme.useToken();
66
+ const headerColors = getSettingsHeaderColors(token);
67
+ const isACLReady = useACLCheckReady(app);
68
+ const { recentItems, search } = useSettingsSearch();
69
+ const [open, setOpen] = useState(false);
70
+ const [keyword, setKeyword] = useState('');
71
+ const [activeIndex, setActiveIndex] = useState(0);
72
+ const inputRef = useRef<InputRef>(null);
73
+ const triggerRef = useRef<HTMLDivElement>(null);
74
+ const lastActiveElementRef = useRef<HTMLElement | null>(null);
75
+ const isPointerDownRef = useRef(false);
76
+
77
+ // 登录 / 找回密码等免鉴权页面共用同一个 shell,这些页面上没有可搜索的配置项。
78
+ const isAuthRoute = app.router.isSkippedAuthCheckRoute(location.pathname);
79
+
80
+ const results = useMemo(() => (keyword.trim() ? search(keyword) : recentItems), [keyword, recentItems, search]);
81
+
82
+ useEffect(() => {
83
+ setActiveIndex(0);
84
+ }, [keyword]);
85
+
86
+ useEffect(() => {
87
+ if (isAuthRoute || !isACLReady) {
88
+ setOpen(false);
89
+ setKeyword('');
90
+ }
91
+ }, [isACLReady, isAuthRoute]);
92
+
93
+ const restoreLastActiveElement = useCallback(() => {
94
+ const lastActiveElement = lastActiveElementRef.current;
95
+ lastActiveElementRef.current = null;
96
+ if (lastActiveElement?.isConnected) {
97
+ lastActiveElement.focus({ preventScroll: true });
98
+ }
99
+ }, []);
100
+
101
+ const openPalette = useCallback(() => {
102
+ setKeyword('');
103
+
104
+ if (open) {
105
+ inputRef.current?.focus();
106
+ return;
107
+ }
108
+
109
+ lastActiveElementRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
110
+ triggerRef.current?.focus();
111
+ setOpen(true);
112
+ }, [open]);
113
+
114
+ const go = useCallback(
115
+ (item?: SettingsSearchItem) => {
116
+ if (!item) {
117
+ return;
118
+ }
119
+
120
+ setOpen(false);
121
+
122
+ if (item.link) {
123
+ window.open(item.link, '_blank', 'noopener,noreferrer');
124
+ return;
125
+ }
126
+
127
+ if (item.path !== location.pathname) {
128
+ navigate(item.path);
129
+ }
130
+ },
131
+ [location.pathname, navigate],
132
+ );
133
+
134
+ useEffect(() => {
135
+ if (isAuthRoute || !isACLReady) {
136
+ return;
137
+ }
138
+
139
+ const onKeyDown = (event: KeyboardEvent) => {
140
+ // 页面内组件可能自己就用 Cmd/Ctrl+F;它们 preventDefault 之后
141
+ // 这个 window 级监听器不该再抢一次。
142
+ if (event.defaultPrevented) {
143
+ return;
144
+ }
145
+
146
+ const hasPlatformModifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
147
+
148
+ if (event.key?.toLowerCase() === 'f' && hasPlatformModifier && !event.altKey && !event.shiftKey) {
149
+ event.preventDefault();
150
+ if (!event.repeat) {
151
+ openPalette();
152
+ }
153
+ }
154
+ };
155
+
156
+ window.addEventListener('keydown', onKeyDown);
157
+ return () => window.removeEventListener('keydown', onKeyDown);
158
+ }, [isACLReady, isAuthRoute, openPalette]);
159
+
160
+ useEffect(() => {
161
+ if (!open) {
162
+ return;
163
+ }
164
+
165
+ const onEscapeKeyDown = (event: KeyboardEvent) => {
166
+ if (event.key !== 'Escape' || event.target !== inputRef.current?.input) {
167
+ return;
168
+ }
169
+
170
+ event.preventDefault();
171
+ event.stopPropagation();
172
+ setOpen(false);
173
+ };
174
+
175
+ const onPointerDown = () => {
176
+ isPointerDownRef.current = true;
177
+ };
178
+
179
+ const onPointerUp = () => {
180
+ isPointerDownRef.current = false;
181
+ };
182
+
183
+ window.addEventListener('keydown', onEscapeKeyDown, true);
184
+ window.addEventListener('pointerdown', onPointerDown, true);
185
+ window.addEventListener('pointerup', onPointerUp, true);
186
+ window.addEventListener('pointercancel', onPointerUp, true);
187
+ return () => {
188
+ isPointerDownRef.current = false;
189
+ window.removeEventListener('keydown', onEscapeKeyDown, true);
190
+ window.removeEventListener('pointerdown', onPointerDown, true);
191
+ window.removeEventListener('pointerup', onPointerUp, true);
192
+ window.removeEventListener('pointercancel', onPointerUp, true);
193
+ };
194
+ }, [open]);
195
+
196
+ const onInputKeyDown = useCallback(
197
+ (event: React.KeyboardEvent) => {
198
+ if (event.key === 'Escape') {
199
+ event.preventDefault();
200
+ event.stopPropagation();
201
+ setOpen(false);
202
+ return;
203
+ }
204
+
205
+ if (event.key === 'ArrowDown') {
206
+ event.preventDefault();
207
+ setActiveIndex((index) => (results.length ? (index + 1) % results.length : 0));
208
+ return;
209
+ }
210
+
211
+ if (event.key === 'ArrowUp') {
212
+ event.preventDefault();
213
+ setActiveIndex((index) => (results.length ? (index - 1 + results.length) % results.length : 0));
214
+ return;
215
+ }
216
+
217
+ if (event.key === 'Enter') {
218
+ event.preventDefault();
219
+ go(results[activeIndex]);
220
+ }
221
+ },
222
+ [activeIndex, go, results],
223
+ );
224
+
225
+ if (isAuthRoute || !isACLReady) {
226
+ return null;
227
+ }
228
+
229
+ return (
230
+ <>
231
+ <div
232
+ ref={triggerRef}
233
+ className={triggerClassName}
234
+ role="button"
235
+ tabIndex={0}
236
+ title={t('Search settings')}
237
+ style={{ color: headerColors.text }}
238
+ onClick={openPalette}
239
+ onKeyDown={(event) => {
240
+ if (event.key === 'Escape' && open) {
241
+ event.preventDefault();
242
+ event.stopPropagation();
243
+ setOpen(false);
244
+ restoreLastActiveElement();
245
+ return;
246
+ }
247
+
248
+ if (event.key === 'Enter' || event.key === ' ') {
249
+ event.preventDefault();
250
+ openPalette();
251
+ }
252
+ }}
253
+ onMouseEnter={(event) => {
254
+ event.currentTarget.style.background = headerColors.bgHover;
255
+ }}
256
+ onMouseLeave={(event) => {
257
+ event.currentTarget.style.background = 'transparent';
258
+ }}
259
+ >
260
+ <SearchOutlined />
261
+ <span style={{ fontSize: token.fontSizeSM, opacity: 0.75 }}>{SHORTCUT_LABEL}</span>
262
+ </div>
263
+
264
+ <Modal
265
+ open={open}
266
+ footer={null}
267
+ closable={false}
268
+ destroyOnClose
269
+ focusTriggerAfterClose={false}
270
+ width={520}
271
+ styles={{ body: { paddingTop: 4 } }}
272
+ afterOpenChange={(opened) => {
273
+ if (opened) {
274
+ inputRef.current?.focus();
275
+ return;
276
+ }
277
+
278
+ restoreLastActiveElement();
279
+ }}
280
+ onCancel={() => setOpen(false)}
281
+ >
282
+ <Input
283
+ ref={inputRef}
284
+ allowClear
285
+ bordered={false}
286
+ placeholder={t('Search settings')}
287
+ prefix={<SearchOutlined style={{ color: token.colorTextDescription }} />}
288
+ size="large"
289
+ value={keyword}
290
+ onChange={(event) => setKeyword(event.target.value)}
291
+ onBlur={(event) => {
292
+ // Browser keyboard handlers may consume Escape before the page sees keydown and only blur the input.
293
+ const nextActiveElement = event.relatedTarget;
294
+ const dialog = event.currentTarget.closest('[role="dialog"]');
295
+ if (
296
+ isPointerDownRef.current ||
297
+ (nextActiveElement instanceof Node && dialog?.contains(nextActiveElement))
298
+ ) {
299
+ return;
300
+ }
301
+
302
+ setOpen(false);
303
+ }}
304
+ onKeyDown={onInputKeyDown}
305
+ />
306
+ <div style={{ borderTop: `${token.lineWidth}px solid ${token.colorSplit}`, margin: '8px -24px 0' }} />
307
+
308
+ {!keyword.trim() && recentItems.length ? (
309
+ <div style={{ color: token.colorTextDescription, fontSize: token.fontSizeSM, padding: '12px 12px 4px' }}>
310
+ {t('Recently visited')}
311
+ </div>
312
+ ) : null}
313
+
314
+ <div className={resultListClassName}>
315
+ {results.length ? (
316
+ results.map((item, index) => (
317
+ <div
318
+ key={item.name}
319
+ className={resultItemClassName}
320
+ style={{ background: index === activeIndex ? token.colorFillQuaternary : 'transparent' }}
321
+ onClick={() => go(item)}
322
+ onMouseEnter={() => setActiveIndex(index)}
323
+ >
324
+ <span style={{ color: token.colorText }}>{item.title}</span>
325
+ <span style={{ color: token.colorTextDescription, fontSize: token.fontSizeSM }}>{item.breadcrumb}</span>
326
+ </div>
327
+ ))
328
+ ) : (
329
+ <Empty
330
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
331
+ description={keyword.trim() ? t('No matching settings') : t('Search settings')}
332
+ style={{ margin: '24px 0' }}
333
+ />
334
+ )}
335
+ </div>
336
+ </Modal>
337
+ </>
338
+ );
339
+ });
340
+
341
+ SettingsSearch.displayName = 'SettingsSearch';
342
+
343
+ export default SettingsSearch;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowModelRenderer } from '@nocobase/flow-engine';
11
+ import { ConfigProvider, Layout, theme as antdTheme, type ThemeConfig } from 'antd';
12
+ import React, { useMemo, type FC } from 'react';
13
+ import { useLocation } from 'react-router-dom';
14
+ import { HelpLite } from '../flow/admin-shell/admin-layout/HelpLite';
15
+ import {
16
+ USER_CENTER_ACTION_ID,
17
+ type UserCenterTopbarActionModel,
18
+ } from '../flow/models/topbar/UserCenterTopbarActionModel';
19
+ import { useApp } from '../hooks/useApp';
20
+ import { useCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
21
+ import { addCustomAlgorithmToTheme } from '../theme/customAlgorithm';
22
+ import { SettingsBrand } from './SettingsBrand';
23
+ import { SettingsGroupNav } from './SettingsGroupNav';
24
+ import { SettingsSearch } from './SettingsSearch';
25
+ import {
26
+ buildSettingsGlobalCss,
27
+ buildSettingsNeutralTheme,
28
+ getSettingsHeaderColors,
29
+ withSettingsHeaderTheme,
30
+ } from './settingsTheme';
31
+ import { useSettingsThemeConfig } from './useSettingsThemeConfig';
32
+
33
+ const rootStyle: React.CSSProperties = {
34
+ height: '100vh',
35
+ minWidth: 0,
36
+ overflow: 'hidden',
37
+ };
38
+
39
+ const headerContentStyle: React.CSSProperties = {
40
+ alignItems: 'center',
41
+ display: 'flex',
42
+ gap: 24,
43
+ height: '100%',
44
+ justifyContent: 'space-between',
45
+ };
46
+
47
+ const actionsStyle: React.CSSProperties = {
48
+ alignItems: 'center',
49
+ display: 'flex',
50
+ flexShrink: 0,
51
+ gap: 8,
52
+ height: '100%',
53
+ };
54
+
55
+ const workspaceStyle: React.CSSProperties = {
56
+ display: 'flex',
57
+ flex: 1,
58
+ minWidth: 0,
59
+ minHeight: 0,
60
+ overflow: 'hidden',
61
+ };
62
+
63
+ const contentStyle: React.CSSProperties = {
64
+ flex: 1,
65
+ minWidth: 0,
66
+ minHeight: 0,
67
+ overflow: 'hidden',
68
+ };
69
+
70
+ const embedContainerStyle: React.CSSProperties = {
71
+ flexShrink: 0,
72
+ height: '100%',
73
+ position: 'relative',
74
+ width: 'fit-content',
75
+ };
76
+
77
+ export const SettingsShell: FC = ({ children }) => {
78
+ const app = useApp();
79
+ const location = useLocation();
80
+ const { token } = antdTheme.useToken();
81
+ // 设置中心的外观由主题编辑器里那条「简约」主题记录约束;读不到才用代码里的中性配色兜底。
82
+ const storedThemeConfig = useSettingsThemeConfig();
83
+ const normalizedStoredThemeConfig = useMemo(
84
+ () =>
85
+ storedThemeConfig
86
+ ? addCustomAlgorithmToTheme({
87
+ ...storedThemeConfig,
88
+ algorithm: Array.isArray(storedThemeConfig.algorithm)
89
+ ? [...storedThemeConfig.algorithm]
90
+ : storedThemeConfig.algorithm,
91
+ })
92
+ : storedThemeConfig,
93
+ [storedThemeConfig],
94
+ );
95
+ // 顶栏和补丁样式要按**设置中心自己这套主题**算色,不能直接用上面那个 `token`——
96
+ // 这个组件在自己的 ConfigProvider 外面,`token` 是业务端主题的。用它的话,业务端一切暗黑,
97
+ // 顶栏就跟着变黑,而内容区还是简约的白,一半黑一半白。
98
+ const settingsToken = useMemo(
99
+ () => (normalizedStoredThemeConfig ? antdTheme.getDesignToken(normalizedStoredThemeConfig) : token),
100
+ [normalizedStoredThemeConfig, token],
101
+ );
102
+ const settingsShellTheme = useMemo<ThemeConfig>(
103
+ () =>
104
+ normalizedStoredThemeConfig
105
+ ? withSettingsHeaderTheme(normalizedStoredThemeConfig, settingsToken)
106
+ : buildSettingsNeutralTheme(token),
107
+ [normalizedStoredThemeConfig, settingsToken, token],
108
+ );
109
+ const headerColors = useMemo(() => getSettingsHeaderColors(settingsToken), [settingsToken]);
110
+ const settingsGlobalCss = useMemo(() => buildSettingsGlobalCss(settingsToken), [settingsToken]);
111
+ const authStatus = useCurrentUserAuthStatus(app);
112
+ const routeMatches = app.router.matchRoutes(location.pathname) || [];
113
+ const isAuthenticationRoute = routeMatches.some((match) => {
114
+ const routeId = match.route.id;
115
+ return routeId === 'auth' || routeId?.startsWith('auth.') || routeId === '2fa' || routeId?.startsWith('2fa.');
116
+ });
117
+ const shouldHideHeader = routeMatches.some((match) => match.route.id === 'settingsDetails.idpOAuth.device');
118
+
119
+ if (isAuthenticationRoute) {
120
+ // 登录 / 二次验证页也要套设置中心自己的主题,否则它跟的是业务端主题:
121
+ // 业务端切暗黑之后登录页是暗的,登进去又变成浅色,中间闪一下。
122
+ // 外面这层用 `display: contents`,只为了让作用在 `.nb-settings-shell` 上的补丁样式
123
+ // 也能命中登录页,不改变任何布局。
124
+ return (
125
+ <ConfigProvider theme={settingsShellTheme}>
126
+ <style>{settingsGlobalCss}</style>
127
+ <div className="nb-settings-shell" style={{ display: 'contents' }}>
128
+ {children}
129
+ </div>
130
+ </ConfigProvider>
131
+ );
132
+ }
133
+
134
+ const shouldShowHeader = authStatus === 'authenticated' && !shouldHideHeader;
135
+ const hasUserCenterModel = Boolean(app.flowEngine.getModelClass('UserCenterTopbarActionModel'));
136
+ const userCenter = hasUserCenterModel
137
+ ? app.flowEngine.getModel<UserCenterTopbarActionModel>(`topbar-action-${USER_CENTER_ACTION_ID}`) ||
138
+ app.flowEngine.createModel<UserCenterTopbarActionModel>({
139
+ use: 'UserCenterTopbarActionModel',
140
+ uid: `topbar-action-${USER_CENTER_ACTION_ID}`,
141
+ })
142
+ : null;
143
+
144
+ return (
145
+ <ConfigProvider theme={settingsShellTheme}>
146
+ <style>{settingsGlobalCss}</style>
147
+ <Layout className="nb-settings-shell" style={rootStyle}>
148
+ <Layout.Header
149
+ style={
150
+ {
151
+ // 深色顶栏自带层次,只有浅色顶栏才需要一条分割线把它和内容区分开。
152
+ borderBottom: headerColors.dark
153
+ ? 'none'
154
+ : `${settingsToken.lineWidth}px solid ${settingsToken.colorBorderSecondary}`,
155
+ display: shouldShowHeader ? undefined : 'none',
156
+ height: 46,
157
+ lineHeight: '46px',
158
+ paddingInline: settingsToken.paddingLG,
159
+ // 顶栏子组件(动作条、pinned 插件列表)有一部分直接读 CSS 变量而不是 antd token,
160
+ // 而且兜底值是按深色顶栏写死的浅色,这里一并覆盖,免得白底上出现白图标。
161
+ '--nb-topbar-action-hover-bg': headerColors.bgHover,
162
+ '--colorTextHeaderMenu': headerColors.text,
163
+ '--nb-topbar-action-color': headerColors.text,
164
+ } as React.CSSProperties
165
+ }
166
+ >
167
+ <div style={headerContentStyle}>
168
+ <SettingsBrand />
169
+ <SettingsGroupNav />
170
+ <div style={actionsStyle}>
171
+ <SettingsSearch />
172
+ <HelpLite />
173
+ {userCenter ? <FlowModelRenderer model={userCenter} /> : null}
174
+ </div>
175
+ </div>
176
+ </Layout.Header>
177
+ <div style={workspaceStyle}>
178
+ <Layout.Content style={contentStyle}>{children}</Layout.Content>
179
+ <div id="nocobase-embed-container" style={embedContainerStyle} />
180
+ </div>
181
+ </Layout>
182
+ </ConfigProvider>
183
+ );
184
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ /**
11
+ * 判断当前跑的是不是设置中心那个壳。
12
+ *
13
+ * 插件用它来决定某个入口要不要出现在设置中心里,不用 import `SettingsApplication`
14
+ * 本身——那会让插件反向依赖壳的实现。
15
+ *
16
+ * @param {any} app 应用实例
17
+ * @returns {boolean} 是设置中心返回 true
18
+ */
19
+ export function isSettingsApp(app?: any): boolean {
20
+ return Boolean(app?.isSettingsApp);
21
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { stripModernClientPrefix } from '../authRedirect';
11
+ import type { BaseApplication } from '../BaseApplication';
12
+ import {
13
+ resolveSettingsAppScope,
14
+ resolveSettingsAppScopeWithinPublicPath,
15
+ resolveSettingsDocumentPath,
16
+ } from './settingsDocumentPath';
17
+
18
+ type SettingsRuntimeApp = Pick<BaseApplication<any>, 'name'> & {
19
+ getPublicPath?: () => string;
20
+ router?: {
21
+ basename?: string;
22
+ getBasename?: () => string | undefined;
23
+ };
24
+ };
25
+
26
+ const LEGACY_EMAIL_OAUTH_PATH = '/admin/settings/mail/oauth2';
27
+
28
+ const ROUTE_MAPPINGS = [
29
+ { from: '/admin/workflow/executions', to: '/settings/workflow/executions' },
30
+ { from: '/admin/workflow/workflows', to: '/settings/workflow/workflows' },
31
+ { from: '/admin/settings', to: '/settings' },
32
+ { from: '/settings', to: '/settings' },
33
+ ] as const;
34
+
35
+ function normalizePathname(pathname?: string) {
36
+ const normalized = `/${String(pathname || '/').trim()}`.replace(/\/{2,}/g, '/');
37
+ return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
38
+ }
39
+
40
+ function splitPathSuffix(pathLike: string) {
41
+ const match = String(pathLike || '').match(/^([^?#]*)(.*)$/);
42
+ return {
43
+ pathname: normalizePathname(match?.[1]),
44
+ suffix: match?.[2] || '',
45
+ };
46
+ }
47
+
48
+ function getRuntimeAppScope(app: SettingsRuntimeApp, pathname: string) {
49
+ const basename = app.router?.getBasename?.() || app.router?.basename;
50
+ const publicPath = app.getPublicPath?.() || '/';
51
+ return (
52
+ resolveSettingsAppScopeWithinPublicPath(publicPath, pathname) ||
53
+ resolveSettingsAppScopeWithinPublicPath(publicPath, basename) ||
54
+ (app.name && app.name !== 'main' ? resolveSettingsAppScope(`/apps/${app.name}`) : '')
55
+ );
56
+ }
57
+
58
+ function getRootPublicPath(app: SettingsRuntimeApp, appScope: string) {
59
+ const basename = app.router?.getBasename?.() || app.router?.basename || '';
60
+ const publicPath = app.getPublicPath?.();
61
+ if (publicPath) {
62
+ return normalizePathname(stripModernClientPrefix(publicPath));
63
+ }
64
+
65
+ const normalizedBasename = normalizePathname(basename);
66
+ const basenameWithoutAppScope =
67
+ appScope && normalizedBasename.endsWith(appScope)
68
+ ? normalizedBasename.slice(0, -appScope.length)
69
+ : normalizedBasename;
70
+ return normalizePathname(stripModernClientPrefix(basenameWithoutAppScope));
71
+ }
72
+
73
+ function findMappedRoute(pathname: string) {
74
+ for (const mapping of ROUTE_MAPPINGS) {
75
+ const index = pathname.indexOf(mapping.from);
76
+ if (index < 0) {
77
+ continue;
78
+ }
79
+ const tail = pathname.slice(index + mapping.from.length);
80
+ if (tail && !tail.startsWith('/')) {
81
+ continue;
82
+ }
83
+ return `${mapping.to}${tail}`;
84
+ }
85
+ return '/settings';
86
+ }
87
+
88
+ export function resolveStandaloneSettingsPath(app: SettingsRuntimeApp, pathLike: string, contextPathname?: string) {
89
+ const { pathname, suffix } = splitPathSuffix(pathLike);
90
+ const appScope = getRuntimeAppScope(app, contextPathname || pathname);
91
+ const rootPublicPath = getRootPublicPath(app, appScope).replace(/\/+$/, '');
92
+ const documentBasePath = `${rootPublicPath}${appScope}`;
93
+ const oauthIndex = pathname.indexOf(LEGACY_EMAIL_OAUTH_PATH);
94
+
95
+ if (oauthIndex >= 0) {
96
+ const oauthTail = pathname.slice(oauthIndex + LEGACY_EMAIL_OAUTH_PATH.length);
97
+ if (!oauthTail || oauthTail.startsWith('/')) {
98
+ return `${documentBasePath}${LEGACY_EMAIL_OAUTH_PATH}${oauthTail}${suffix}`;
99
+ }
100
+ }
101
+
102
+ return `${resolveSettingsDocumentPath(rootPublicPath, appScope, findMappedRoute(pathname))}${suffix}`;
103
+ }