@nocobase/client-v2 2.3.0-alpha.1 → 3.0.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 (123) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/actions/linkageRules.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/FlowRoute.d.ts +2 -2
  9. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  10. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  11. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  12. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  14. package/es/index.d.ts +4 -1
  15. package/es/index.mjs +139 -125
  16. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  17. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  18. package/es/settings-app/SettingsApplication.d.ts +18 -0
  19. package/es/settings-app/SettingsBrand.d.ts +16 -0
  20. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  21. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  22. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  23. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  24. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  25. package/es/settings-app/SettingsSearch.d.ts +17 -0
  26. package/es/settings-app/SettingsShell.d.ts +10 -0
  27. package/es/settings-app/isSettingsApp.d.ts +18 -0
  28. package/es/settings-app/runtimePaths.d.ts +18 -0
  29. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  30. package/es/settings-app/settingsTheme.d.ts +87 -0
  31. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  32. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  33. package/es/settings-center/groups.d.ts +66 -0
  34. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  35. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  36. package/es/settings-center/utils.d.ts +24 -0
  37. package/es/theme/index.d.ts +1 -0
  38. package/es/theme/itemActive.d.ts +24 -0
  39. package/es/theme/type.d.ts +9 -0
  40. package/lib/index.js +141 -127
  41. package/lib/locale/languageCodes.js +1 -0
  42. package/package.json +8 -7
  43. package/src/__tests__/authRedirect.test.ts +137 -0
  44. package/src/__tests__/browserChecker.test.ts +124 -6
  45. package/src/__tests__/mockSettingsApplication.ts +29 -0
  46. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +51 -10
  47. package/src/__tests__/plugin-manager.test.tsx +7 -6
  48. package/src/__tests__/settings-application.test.ts +164 -0
  49. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  50. package/src/__tests__/settings-center.test.tsx +38 -37
  51. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  52. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  53. package/src/__tests__/settings-shell.test.tsx +200 -0
  54. package/src/acl/ACLProvider.tsx +21 -0
  55. package/src/authRedirect.ts +72 -3
  56. package/src/components/AttachmentUpload.tsx +275 -0
  57. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  58. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  59. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  60. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  63. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  64. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  65. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  66. package/src/flow/actions/linkageRules.tsx +26 -6
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  68. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  69. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FlowRoute.tsx +71 -29
  74. package/src/flow/index.ts +2 -0
  75. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  76. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  77. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  78. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  79. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  80. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  81. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  82. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  83. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  85. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  86. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  87. package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
  88. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  89. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  90. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  93. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  94. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  95. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  96. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  97. package/src/index.ts +10 -1
  98. package/src/locale/languageCodes.ts +1 -0
  99. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  100. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  101. package/src/settings-app/SettingsApplication.ts +31 -0
  102. package/src/settings-app/SettingsBrand.tsx +108 -0
  103. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  104. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  105. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  106. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  107. package/src/settings-app/SettingsRouterManager.ts +92 -0
  108. package/src/settings-app/SettingsSearch.tsx +238 -0
  109. package/src/settings-app/SettingsShell.tsx +167 -0
  110. package/src/settings-app/isSettingsApp.ts +21 -0
  111. package/src/settings-app/runtimePaths.ts +104 -0
  112. package/src/settings-app/settingsDocumentPath.ts +66 -0
  113. package/src/settings-app/settingsTheme.ts +301 -0
  114. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  115. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  116. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  117. package/src/settings-center/groups.ts +108 -0
  118. package/src/settings-center/useSettingsGroups.ts +105 -0
  119. package/src/settings-center/useSettingsSearch.ts +173 -0
  120. package/src/settings-center/utils.tsx +50 -1
  121. package/src/theme/index.tsx +1 -0
  122. package/src/theme/itemActive.ts +31 -0
  123. package/src/theme/type.ts +10 -0
@@ -0,0 +1,108 @@
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 { css } from '@emotion/css';
11
+ import { observer } from '@nocobase/flow-engine';
12
+ import { theme as antdTheme } from 'antd';
13
+ import React from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { useSystemSettings } from '../flow/system-settings';
16
+ import { getSettingsHeaderColors } from './settingsTheme';
17
+
18
+ const brandClassName = css`
19
+ display: inline-flex;
20
+ align-items: center;
21
+ gap: 8px;
22
+ height: 100%;
23
+ flex-shrink: 0;
24
+ min-width: 0;
25
+ `;
26
+
27
+ const logoImageClassName = css`
28
+ height: 24px;
29
+ width: auto;
30
+ max-width: 140px;
31
+ object-fit: contain;
32
+ display: block;
33
+ `;
34
+
35
+ const titleClassName = css`
36
+ font-weight: 600;
37
+ line-height: 1;
38
+ white-space: nowrap;
39
+ overflow: hidden;
40
+ text-overflow: ellipsis;
41
+ `;
42
+
43
+ const suffixClassName = css`
44
+ line-height: 1;
45
+ white-space: nowrap;
46
+ font-weight: 400;
47
+ `;
48
+
49
+ /**
50
+ * 设置中心顶栏品牌区。
51
+ *
52
+ * 沿用系统设置里的站点 logo / 标题,并在其后补一个弱化的「设置中心」副标题,
53
+ * 用于区分设置中心和业务端。
54
+ */
55
+ export const SettingsBrand = observer(() => {
56
+ const { token } = antdTheme.useToken();
57
+ const headerColors = getSettingsHeaderColors(token);
58
+ const result = useSystemSettings();
59
+ const { t } = useTranslation();
60
+ const { t: tCollections } = useTranslation('lm-collections');
61
+ const title = tCollections(result?.data?.data?.title || 'NocoBase');
62
+ const logoUrl = result?.data?.data?.logo?.url;
63
+
64
+ if (result?.loading) {
65
+ return <div className={brandClassName} />;
66
+ }
67
+
68
+ return (
69
+ <div className={brandClassName}>
70
+ {logoUrl ? (
71
+ // 站点 logo 通常是为深色顶栏画的浅色图;设置中心顶栏是灰白的,
72
+ // 统一压成单色,避免浅色 logo 直接消失在白底上。
73
+ <img
74
+ alt={title}
75
+ className={logoImageClassName}
76
+ src={logoUrl}
77
+ style={{ filter: headerColors.dark ? 'brightness(0) invert(1)' : 'brightness(0)' }}
78
+ />
79
+ ) : (
80
+ <span className={titleClassName} style={{ color: headerColors.text, fontSize: token.fontSizeHeading4 }}>
81
+ {title}
82
+ </span>
83
+ )}
84
+ <span
85
+ aria-hidden
86
+ style={{
87
+ alignSelf: 'center',
88
+ background: headerColors.text,
89
+ height: 16,
90
+ opacity: 0.3,
91
+ width: 1,
92
+ }}
93
+ />
94
+ <span
95
+ className={suffixClassName}
96
+ style={{
97
+ color: headerColors.text,
98
+ fontSize: token.fontSize,
99
+ opacity: 0.75,
100
+ }}
101
+ >
102
+ {t('Settings center')}
103
+ </span>
104
+ </div>
105
+ );
106
+ });
107
+
108
+ SettingsBrand.displayName = 'SettingsBrand';
@@ -0,0 +1,10 @@
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
+ export { SettingsBuildInPlugin } from '../nocobase-buildin-plugin';
@@ -0,0 +1,26 @@
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 React, { useEffect } from 'react';
11
+ import { useLocation } from 'react-router-dom';
12
+ import { useApp } from '../hooks/useApp';
13
+ import { resolveStandaloneSettingsPath } from './runtimePaths';
14
+
15
+ export function SettingsDocumentRedirect() {
16
+ const app = useApp();
17
+ const location = useLocation();
18
+ const sourcePath = `${location.pathname}${location.search}${location.hash}`;
19
+ const targetPath = resolveStandaloneSettingsPath(app, sourcePath, location.pathname);
20
+
21
+ useEffect(() => {
22
+ window.location.replace(targetPath);
23
+ }, [targetPath]);
24
+
25
+ return app.renderComponent('AppSpin');
26
+ }
@@ -0,0 +1,124 @@
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 { observer } from '@nocobase/flow-engine';
11
+ import { ConfigProvider, Menu, theme as antdTheme } from 'antd';
12
+ import React, { useCallback, useMemo } from 'react';
13
+ import { useTranslation } from 'react-i18next';
14
+ import { useLocation, useNavigate } from 'react-router-dom';
15
+ import { useApp } from '../hooks/useApp';
16
+ import { useSettingsGroups } from '../settings-center/useSettingsGroups';
17
+ import { buildSettingsHeaderMenuTheme } from './settingsTheme';
18
+
19
+ const navStyle: React.CSSProperties = {
20
+ background: 'transparent',
21
+ borderBottom: 'none',
22
+ flex: 'auto',
23
+ minWidth: 0,
24
+ lineHeight: '46px',
25
+ };
26
+
27
+ /**
28
+ * 设置中心顶栏的一级导航。
29
+ *
30
+ * 应用、插件管理各自只有一个页面,直接当一级入口;其余全部收在「系统设置」下,
31
+ * 鼠标移上去展开下拉,也可以点进去从左栏走。
32
+ */
33
+ export const SettingsGroupNav: React.FC = observer(() => {
34
+ const { t } = useTranslation();
35
+ const app = useApp();
36
+ const navigate = useNavigate();
37
+ const location = useLocation();
38
+ const { token } = antdTheme.useToken();
39
+ const { groups, activeGroupKey, currentTopLevelSetting, getGroupEntryPath } = useSettingsGroups();
40
+ const headerMenuTheme = useMemo(() => buildSettingsHeaderMenuTheme(token), [token]);
41
+ // 登录 / 找回密码等免鉴权页面共用同一个 shell,这些页面上不该出现设置导航。
42
+ const isAuthRoute = app.router.isSkippedAuthCheckRoute(location.pathname);
43
+
44
+ const handleClick = useCallback(
45
+ ({ key }: { key: string }) => {
46
+ const [groupKey, settingName] = key.split('::');
47
+ const targetPath = settingName
48
+ ? groups.find((group) => group.key === groupKey)?.settings.find((item) => item.name === settingName)?.path
49
+ : getGroupEntryPath(groupKey);
50
+
51
+ if (targetPath && targetPath !== location.pathname) {
52
+ navigate(targetPath);
53
+ }
54
+ },
55
+ [getGroupEntryPath, groups, location.pathname, navigate],
56
+ );
57
+
58
+ // 单入口分组直接当一级项,标题取配置项自己的名字(顺带继承插件的翻译);
59
+ // 系统设置这种大分组挂上子项,鼠标移上去展开。
60
+ const items = useMemo(
61
+ () =>
62
+ groups.map((group) => {
63
+ if (group.settings.length <= 1) {
64
+ return {
65
+ key: group.key,
66
+ label: group.settings[0]?.label ?? t(group.title),
67
+ icon: group.settings[0]?.icon,
68
+ };
69
+ }
70
+
71
+ // 已经站在这个分组里时不再弹下拉:左栏已经把同一份内容铺开了,再浮一层纯属打扰。
72
+ if (group.key === activeGroupKey) {
73
+ return { key: group.key, label: t(group.title) };
74
+ }
75
+
76
+ return {
77
+ key: group.key,
78
+ label: t(group.title),
79
+ children: group.settings
80
+ .filter((setting) => !setting.hidden)
81
+ .map((setting) => ({
82
+ key: `${group.key}::${setting.name}`,
83
+ label: setting.label ?? setting.title,
84
+ icon: setting.icon,
85
+ })),
86
+ // 点标题本身也要能进去,而不是只能从下拉里挑。
87
+ onTitleClick: handleClick,
88
+ };
89
+ }),
90
+ [activeGroupKey, groups, handleClick, t],
91
+ );
92
+
93
+ // 子菜单标题只有在它的某个子项被选中时才会高亮,所以两个 key 都要给。
94
+ const selectedKeys = useMemo(() => {
95
+ if (!activeGroupKey) {
96
+ return [];
97
+ }
98
+ return currentTopLevelSetting?.name
99
+ ? [activeGroupKey, `${activeGroupKey}::${currentTopLevelSetting.name}`]
100
+ : [activeGroupKey];
101
+ }, [activeGroupKey, currentTopLevelSetting?.name]);
102
+
103
+ if (isAuthRoute || items.length <= 1) {
104
+ return <div style={{ flex: 'auto' }} />;
105
+ }
106
+
107
+ return (
108
+ <ConfigProvider theme={headerMenuTheme}>
109
+ <Menu
110
+ mode="horizontal"
111
+ disabledOverflow={false}
112
+ selectedKeys={selectedKeys}
113
+ items={items}
114
+ style={navStyle}
115
+ triggerSubMenuAction="hover"
116
+ onClick={handleClick}
117
+ />
118
+ </ConfigProvider>
119
+ );
120
+ });
121
+
122
+ SettingsGroupNav.displayName = 'SettingsGroupNav';
123
+
124
+ export default SettingsGroupNav;
@@ -0,0 +1,38 @@
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 type { BaseApplication } from '../BaseApplication';
11
+ import { PluginSettingsManager } from '../PluginSettingsManager';
12
+ import { resolveSettingsAppScopeWithinPublicPath } from './settingsDocumentPath';
13
+
14
+ const SETTINGS_ROUTE_PREFIX = 'settings.';
15
+ const MAIN_SETTINGS_PATH_PREFIX = '/settings/';
16
+
17
+ export class SettingsPluginSettingsManager<
18
+ TApp extends BaseApplication<any> = BaseApplication<any>,
19
+ > extends PluginSettingsManager<TApp> {
20
+ getRouteName(name: string) {
21
+ return `${SETTINGS_ROUTE_PREFIX}${name}`;
22
+ }
23
+
24
+ getRoutePath(name: string) {
25
+ const appScope = resolveSettingsAppScopeWithinPublicPath(this.app.getPublicPath(), this.app.router.getBasename?.());
26
+ const pathPrefix = appScope ? '/' : MAIN_SETTINGS_PATH_PREFIX;
27
+ const separatorIndex = name.indexOf('.');
28
+ const menuName = separatorIndex < 0 ? name : name.slice(0, separatorIndex);
29
+ const pageName = separatorIndex < 0 ? undefined : name.slice(separatorIndex + 1);
30
+ const menuPath = `${pathPrefix}${menuName}`;
31
+
32
+ if (!pageName || pageName === 'index') {
33
+ return menuPath;
34
+ }
35
+
36
+ return `${menuPath}/${pageName}`;
37
+ }
38
+ }
@@ -0,0 +1,92 @@
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 type { BaseApplication } from '../BaseApplication';
11
+ import { RouterManager, type RouteType } from '../RouterManager';
12
+ import { resolveSettingsAppScopeWithinPublicPath } from './settingsDocumentPath';
13
+
14
+ function isSettingsOwnedRoute(name: string) {
15
+ return (
16
+ name === 'not-found' ||
17
+ name === 'settings' ||
18
+ name.startsWith('settings.') ||
19
+ name === 'settingsDetails' ||
20
+ name.startsWith('settingsDetails.') ||
21
+ name === 'auth' ||
22
+ name.startsWith('auth.') ||
23
+ name === '2fa' ||
24
+ name.startsWith('2fa.')
25
+ );
26
+ }
27
+
28
+ function isSettingsAuthenticationRoute(name: string) {
29
+ return name === 'auth' || name.startsWith('auth.') || name === '2fa' || name.startsWith('2fa.');
30
+ }
31
+
32
+ export class SettingsRouterManager<
33
+ TApp extends BaseApplication<any> = BaseApplication<any>,
34
+ > extends RouterManager<TApp> {
35
+ private rebaseScopedSettingsRoute(route: RouteType) {
36
+ const appScope = resolveSettingsAppScopeWithinPublicPath(this.app.getPublicPath(), this.getBasename());
37
+ if (!route.path?.startsWith('/') || !appScope) {
38
+ return route;
39
+ }
40
+ if (route.path === '/settings') {
41
+ return {
42
+ ...route,
43
+ path: '/',
44
+ };
45
+ }
46
+ if (!route.path.startsWith('/settings/')) {
47
+ return route;
48
+ }
49
+ return {
50
+ ...route,
51
+ path: route.path.slice('/settings'.length),
52
+ };
53
+ }
54
+
55
+ private rebaseAuthenticationRoute(route: RouteType) {
56
+ if (!route.path?.startsWith('/')) {
57
+ return route;
58
+ }
59
+
60
+ const settingsRoot = this.app.pluginSettingsManager.getRoutePath('').replace(/\/+$/, '');
61
+ if (!settingsRoot) {
62
+ return route;
63
+ }
64
+ if (route.path === settingsRoot || route.path.startsWith(`${settingsRoot}/`)) {
65
+ return route;
66
+ }
67
+
68
+ return {
69
+ ...route,
70
+ path: `${settingsRoot}/${route.path.replace(/^\/+/, '')}`,
71
+ };
72
+ }
73
+
74
+ add(name: string, route: RouteType) {
75
+ if (!isSettingsOwnedRoute(name)) {
76
+ return;
77
+ }
78
+
79
+ const scopedRoute = this.rebaseScopedSettingsRoute(route);
80
+ const ownedRoute = isSettingsAuthenticationRoute(name) ? this.rebaseAuthenticationRoute(scopedRoute) : scopedRoute;
81
+
82
+ super.add(
83
+ name,
84
+ name === 'settingsDetails' || name.startsWith('settingsDetails.')
85
+ ? {
86
+ ...ownedRoute,
87
+ authCheck: true,
88
+ }
89
+ : ownedRoute,
90
+ );
91
+ }
92
+ }
@@ -0,0 +1,238 @@
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 { useApp } from '../hooks/useApp';
19
+ import { useSettingsSearch, type SettingsSearchItem } from '../settings-center/useSettingsSearch';
20
+ import { getSettingsHeaderColors } from './settingsTheme';
21
+
22
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
23
+ const SHORTCUT_LABEL = isMac ? '⌘K' : 'Ctrl K';
24
+
25
+ const triggerClassName = css`
26
+ align-items: center;
27
+ border-radius: 6px;
28
+ cursor: pointer;
29
+ display: inline-flex;
30
+ gap: 6px;
31
+ height: 28px;
32
+ padding: 0 10px;
33
+ transition: background 0.2s;
34
+ white-space: nowrap;
35
+ `;
36
+
37
+ const resultListClassName = css`
38
+ max-height: 320px;
39
+ overflow-y: auto;
40
+ margin: 0 -24px -12px;
41
+ padding: 0 12px 12px;
42
+ `;
43
+
44
+ const resultItemClassName = css`
45
+ align-items: baseline;
46
+ border-radius: 6px;
47
+ cursor: pointer;
48
+ display: flex;
49
+ gap: 8px;
50
+ padding: 8px 12px;
51
+ `;
52
+
53
+ /**
54
+ * 设置中心的搜索入口。
55
+ *
56
+ * 顶栏放一个轻量触发器,真正的搜索在弹层里进行;关键词为空时展示最近访问,
57
+ * 这样分组化之后被收进左侧栏的深层页面仍然一步可达。支持 `Cmd/Ctrl + K` 唤起。
58
+ */
59
+ export const SettingsSearch: React.FC = observer(() => {
60
+ const { t } = useTranslation();
61
+ const app = useApp();
62
+ const navigate = useNavigate();
63
+ const location = useLocation();
64
+ const { token } = antdTheme.useToken();
65
+ const headerColors = getSettingsHeaderColors(token);
66
+ const { recentItems, search } = useSettingsSearch();
67
+ const [open, setOpen] = useState(false);
68
+ const [keyword, setKeyword] = useState('');
69
+ const [activeIndex, setActiveIndex] = useState(0);
70
+ const inputRef = useRef<InputRef>(null);
71
+
72
+ // 登录 / 找回密码等免鉴权页面共用同一个 shell,这些页面上没有可搜索的配置项。
73
+ const isAuthRoute = app.router.isSkippedAuthCheckRoute(location.pathname);
74
+
75
+ const results = useMemo(() => (keyword.trim() ? search(keyword) : recentItems), [keyword, recentItems, search]);
76
+
77
+ useEffect(() => {
78
+ setActiveIndex(0);
79
+ }, [keyword]);
80
+
81
+ const openPalette = useCallback(() => {
82
+ setKeyword('');
83
+ setOpen(true);
84
+ }, []);
85
+
86
+ const go = useCallback(
87
+ (item?: SettingsSearchItem) => {
88
+ if (!item) {
89
+ return;
90
+ }
91
+
92
+ setOpen(false);
93
+
94
+ if (item.link) {
95
+ window.open(item.link, '_blank', 'noopener,noreferrer');
96
+ return;
97
+ }
98
+
99
+ if (item.path !== location.pathname) {
100
+ navigate(item.path);
101
+ }
102
+ },
103
+ [location.pathname, navigate],
104
+ );
105
+
106
+ useEffect(() => {
107
+ if (isAuthRoute) {
108
+ return;
109
+ }
110
+
111
+ const onKeyDown = (event: KeyboardEvent) => {
112
+ // 页面内组件(代码编辑器之类)可能自己就用 Cmd/Ctrl+K;它们 preventDefault 之后
113
+ // 这个 window 级监听器不该再抢一次。
114
+ if (event.defaultPrevented) {
115
+ return;
116
+ }
117
+
118
+ if (event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
119
+ event.preventDefault();
120
+ openPalette();
121
+ }
122
+ };
123
+
124
+ window.addEventListener('keydown', onKeyDown);
125
+ return () => window.removeEventListener('keydown', onKeyDown);
126
+ }, [isAuthRoute, openPalette]);
127
+
128
+ const onInputKeyDown = useCallback(
129
+ (event: React.KeyboardEvent) => {
130
+ if (event.key === 'ArrowDown') {
131
+ event.preventDefault();
132
+ setActiveIndex((index) => (results.length ? (index + 1) % results.length : 0));
133
+ return;
134
+ }
135
+
136
+ if (event.key === 'ArrowUp') {
137
+ event.preventDefault();
138
+ setActiveIndex((index) => (results.length ? (index - 1 + results.length) % results.length : 0));
139
+ return;
140
+ }
141
+
142
+ if (event.key === 'Enter') {
143
+ event.preventDefault();
144
+ go(results[activeIndex]);
145
+ }
146
+ },
147
+ [activeIndex, go, results],
148
+ );
149
+
150
+ if (isAuthRoute) {
151
+ return null;
152
+ }
153
+
154
+ return (
155
+ <>
156
+ <div
157
+ className={triggerClassName}
158
+ role="button"
159
+ tabIndex={0}
160
+ title={t('Search settings')}
161
+ style={{ color: headerColors.text }}
162
+ onClick={openPalette}
163
+ onKeyDown={(event) => {
164
+ if (event.key === 'Enter' || event.key === ' ') {
165
+ event.preventDefault();
166
+ openPalette();
167
+ }
168
+ }}
169
+ onMouseEnter={(event) => {
170
+ event.currentTarget.style.background = headerColors.bgHover;
171
+ }}
172
+ onMouseLeave={(event) => {
173
+ event.currentTarget.style.background = 'transparent';
174
+ }}
175
+ >
176
+ <SearchOutlined />
177
+ <span style={{ fontSize: token.fontSizeSM, opacity: 0.75 }}>{SHORTCUT_LABEL}</span>
178
+ </div>
179
+
180
+ <Modal
181
+ open={open}
182
+ footer={null}
183
+ closable={false}
184
+ destroyOnClose
185
+ width={520}
186
+ styles={{ body: { paddingTop: 4 } }}
187
+ afterOpenChange={(opened) => opened && inputRef.current?.focus()}
188
+ onCancel={() => setOpen(false)}
189
+ >
190
+ <Input
191
+ ref={inputRef}
192
+ allowClear
193
+ bordered={false}
194
+ placeholder={t('Search settings')}
195
+ prefix={<SearchOutlined style={{ color: token.colorTextDescription }} />}
196
+ size="large"
197
+ value={keyword}
198
+ onChange={(event) => setKeyword(event.target.value)}
199
+ onKeyDown={onInputKeyDown}
200
+ />
201
+ <div style={{ borderTop: `${token.lineWidth}px solid ${token.colorSplit}`, margin: '8px -24px 0' }} />
202
+
203
+ {!keyword.trim() && recentItems.length ? (
204
+ <div style={{ color: token.colorTextDescription, fontSize: token.fontSizeSM, padding: '12px 12px 4px' }}>
205
+ {t('Recently visited')}
206
+ </div>
207
+ ) : null}
208
+
209
+ <div className={resultListClassName}>
210
+ {results.length ? (
211
+ results.map((item, index) => (
212
+ <div
213
+ key={item.name}
214
+ className={resultItemClassName}
215
+ style={{ background: index === activeIndex ? token.colorFillQuaternary : 'transparent' }}
216
+ onClick={() => go(item)}
217
+ onMouseEnter={() => setActiveIndex(index)}
218
+ >
219
+ <span style={{ color: token.colorText }}>{item.title}</span>
220
+ <span style={{ color: token.colorTextDescription, fontSize: token.fontSizeSM }}>{item.breadcrumb}</span>
221
+ </div>
222
+ ))
223
+ ) : (
224
+ <Empty
225
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
226
+ description={keyword.trim() ? t('No matching settings') : t('Search settings')}
227
+ style={{ margin: '24px 0' }}
228
+ />
229
+ )}
230
+ </div>
231
+ </Modal>
232
+ </>
233
+ );
234
+ });
235
+
236
+ SettingsSearch.displayName = 'SettingsSearch';
237
+
238
+ export default SettingsSearch;