@nocobase/client-v2 3.0.0-alpha.1 → 3.0.0-alpha.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 (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { FlowEngine, FlowEngineProvider, observer } from '@nocobase/flow-engine';
11
- import { render, screen, waitFor } from '@testing-library/react';
11
+ import { act, render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { createMemoryRouter, Outlet, RouterProvider, useOutlet } from 'react-router-dom';
14
14
  import { describe, expect, it, vi } from 'vitest';
@@ -106,6 +106,64 @@ describe('LayoutRoute', () => {
106
106
  });
107
107
  });
108
108
 
109
+ it('syncs updated router state when navigating to the same layout pathname', async () => {
110
+ const syncLayoutRoute = vi.fn();
111
+
112
+ class StateTrackingLayoutModel extends TestLayoutModel {
113
+ syncLayoutRoute(routeLike: Parameters<BaseLayoutModel['syncLayoutRoute']>[0]) {
114
+ syncLayoutRoute(routeLike);
115
+ return super.syncLayoutRoute(routeLike);
116
+ }
117
+ }
118
+
119
+ const stateTrackingLayout: LayoutDefinition = {
120
+ ...layout,
121
+ layoutModelClass: 'StateTrackingLayoutModel',
122
+ };
123
+ const engine = new FlowEngine();
124
+ engine.registerModels({ StateTrackingLayoutModel });
125
+ engine.context.defineProperty('app', {
126
+ value: {
127
+ layoutManager: {
128
+ getLayout: () => stateTrackingLayout,
129
+ },
130
+ },
131
+ });
132
+
133
+ const router = createMemoryRouter(
134
+ [
135
+ {
136
+ id: layout.routeName,
137
+ path: layout.routePath,
138
+ element: <LayoutRoute layoutRouteName="test" />,
139
+ },
140
+ ],
141
+ {
142
+ initialEntries: ['/test'],
143
+ },
144
+ );
145
+
146
+ render(
147
+ <FlowEngineProvider engine={engine}>
148
+ <RouterProvider router={router} />
149
+ </FlowEngineProvider>,
150
+ );
151
+
152
+ expect(await screen.findByTestId('layout-route')).toHaveTextContent('test');
153
+ syncLayoutRoute.mockClear();
154
+
155
+ const routeState = {
156
+ __nocobaseOpenViewInputArgs: { popup: { formData: { status: 'todo' } } },
157
+ };
158
+ await act(async () => {
159
+ await router.navigate('/test', { state: routeState });
160
+ });
161
+
162
+ await waitFor(() => {
163
+ expect(syncLayoutRoute).toHaveBeenCalledWith(expect.objectContaining({ state: routeState }));
164
+ });
165
+ });
166
+
109
167
  it('does not activate desktop route loading for generic layouts', async () => {
110
168
  const activateLayout = vi.fn(() => vi.fn());
111
169
  const engine = new FlowEngine();
@@ -413,6 +471,34 @@ describe('LayoutContentRoute', () => {
413
471
  });
414
472
  });
415
473
 
474
+ it('syncs updated router state when navigating to the same content pathname', async () => {
475
+ const syncLayoutRoute = vi.fn();
476
+
477
+ class StateTrackingLayoutModel extends TestLayoutModel {
478
+ syncLayoutRoute(routeLike: Parameters<BaseLayoutModel['syncLayoutRoute']>[0]) {
479
+ syncLayoutRoute(routeLike);
480
+ return super.syncLayoutRoute(routeLike);
481
+ }
482
+ }
483
+
484
+ const { router } = setup('/test/page-1/view/popup', layout, StateTrackingLayoutModel);
485
+ await waitFor(() => {
486
+ expect(syncLayoutRoute).toHaveBeenCalled();
487
+ });
488
+ syncLayoutRoute.mockClear();
489
+
490
+ const routeState = {
491
+ __nocobaseOpenViewInputArgs: { popup: { formData: { status: 'todo' } } },
492
+ };
493
+ await act(async () => {
494
+ await router.navigate('/test/page-1/view/popup', { state: routeState });
495
+ });
496
+
497
+ await waitFor(() => {
498
+ expect(syncLayoutRoute).toHaveBeenCalledWith(expect.objectContaining({ state: routeState }));
499
+ });
500
+ });
501
+
416
502
  it('parses nested layout route by matched layout pathname', async () => {
417
503
  const nestedLayout: LayoutDefinition = {
418
504
  ...layout,
@@ -181,6 +181,18 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
181
181
  return <>{children}</>;
182
182
  };
183
183
 
184
+ export function resolveUnauthenticatedSignInRoute(app: Application, pathname: string) {
185
+ const matchedRoutes = app.router.matchRoutes(pathname) || [];
186
+ for (let index = matchedRoutes.length - 1; index >= 0; index -= 1) {
187
+ const pathnameBase = matchedRoutes[index].pathnameBase.replace(/\/+$/g, '');
188
+ const candidate = `${pathnameBase}/signin`;
189
+ if (app.router.isSkippedAuthCheckRoute(candidate)) {
190
+ return candidate;
191
+ }
192
+ }
193
+ return '/signin';
194
+ }
195
+
184
196
  function redirectUnauthenticatedRoute(
185
197
  app: Application,
186
198
  location: { pathname: string; search?: string; hash?: string },
@@ -191,7 +203,8 @@ function redirectUnauthenticatedRoute(
191
203
  redirectToV2Signin(app, redirectPath);
192
204
  return;
193
205
  }
194
- navigate(`/signin?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
206
+ const signInPath = resolveUnauthenticatedSignInRoute(app, location.pathname);
207
+ navigate(`${signInPath}?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
195
208
  }
196
209
 
197
210
  const CurrentUserProvider: FC = ({ children }) => {
@@ -12,6 +12,7 @@ import { ConfigProvider, Menu, theme as antdTheme } from 'antd';
12
12
  import React, { useCallback, useMemo } from 'react';
13
13
  import { useTranslation } from 'react-i18next';
14
14
  import { useLocation, useNavigate } from 'react-router-dom';
15
+ import { useACLCheckReady } from '../acl/aclCheckReadiness';
15
16
  import { useApp } from '../hooks/useApp';
16
17
  import { useSettingsGroups } from '../settings-center/useSettingsGroups';
17
18
  import { buildSettingsHeaderMenuTheme } from './settingsTheme';
@@ -27,8 +28,9 @@ const navStyle: React.CSSProperties = {
27
28
  /**
28
29
  * 设置中心顶栏的一级导航。
29
30
  *
30
- * 应用、插件管理各自只有一个页面,直接当一级入口;其余全部收在「系统设置」下,
31
- * 鼠标移上去展开下拉,也可以点进去从左栏走。
31
+ * Applications and the plugin manager have a single page each and act as top-level
32
+ * entries; everything else sits under "other settings" and expands on hover — that
33
+ * group has no sidebar, so the dropdown is the only way into its pages.
32
34
  */
33
35
  export const SettingsGroupNav: React.FC = observer(() => {
34
36
  const { t } = useTranslation();
@@ -37,6 +39,7 @@ export const SettingsGroupNav: React.FC = observer(() => {
37
39
  const location = useLocation();
38
40
  const { token } = antdTheme.useToken();
39
41
  const { groups, activeGroupKey, currentTopLevelSetting, getGroupEntryPath } = useSettingsGroups();
42
+ const isACLReady = useACLCheckReady(app);
40
43
  const headerMenuTheme = useMemo(() => buildSettingsHeaderMenuTheme(token), [token]);
41
44
  // 登录 / 找回密码等免鉴权页面共用同一个 shell,这些页面上不该出现设置导航。
42
45
  const isAuthRoute = app.router.isSkippedAuthCheckRoute(location.pathname);
@@ -68,11 +71,8 @@ export const SettingsGroupNav: React.FC = observer(() => {
68
71
  };
69
72
  }
70
73
 
71
- // 已经站在这个分组里时不再弹下拉:左栏已经把同一份内容铺开了,再浮一层纯属打扰。
72
- if (group.key === activeGroupKey) {
73
- return { key: group.key, label: t(group.title) };
74
- }
75
-
74
+ // The dropdown still opens while standing inside the group: with the sidebar
75
+ // gone, this is the only way to reach the other pages in it.
76
76
  return {
77
77
  key: group.key,
78
78
  label: t(group.title),
@@ -83,11 +83,11 @@ export const SettingsGroupNav: React.FC = observer(() => {
83
83
  label: setting.label ?? setting.title,
84
84
  icon: setting.icon,
85
85
  })),
86
- // 点标题本身也要能进去,而不是只能从下拉里挑。
87
- onTitleClick: handleClick,
86
+ // The group title only expands the dropdown and is not clickable: it is not a
87
+ // page, so clicking it has no well-defined destination. Pick a page instead.
88
88
  };
89
89
  }),
90
- [activeGroupKey, groups, handleClick, t],
90
+ [groups, handleClick, t],
91
91
  );
92
92
 
93
93
  // 子菜单标题只有在它的某个子项被选中时才会高亮,所以两个 key 都要给。
@@ -100,7 +100,7 @@ export const SettingsGroupNav: React.FC = observer(() => {
100
100
  : [activeGroupKey];
101
101
  }, [activeGroupKey, currentTopLevelSetting?.name]);
102
102
 
103
- if (isAuthRoute || items.length <= 1) {
103
+ if (isAuthRoute || !isACLReady || items.length <= 1) {
104
104
  return <div style={{ flex: 'auto' }} />;
105
105
  }
106
106
 
@@ -15,12 +15,13 @@ import type { InputRef } from 'antd';
15
15
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { useLocation, useNavigate } from 'react-router-dom';
18
+ import { useACLCheckReady } from '../acl/aclCheckReadiness';
18
19
  import { useApp } from '../hooks/useApp';
19
20
  import { useSettingsSearch, type SettingsSearchItem } from '../settings-center/useSettingsSearch';
20
21
  import { getSettingsHeaderColors } from './settingsTheme';
21
22
 
22
23
  const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '');
23
- const SHORTCUT_LABEL = isMac ? '⌘K' : 'Ctrl K';
24
+ const SHORTCUT_LABEL = isMac ? '⌘F' : 'Ctrl F';
24
25
 
25
26
  const triggerClassName = css`
26
27
  align-items: center;
@@ -54,7 +55,7 @@ const resultItemClassName = css`
54
55
  * 设置中心的搜索入口。
55
56
  *
56
57
  * 顶栏放一个轻量触发器,真正的搜索在弹层里进行;关键词为空时展示最近访问,
57
- * 这样分组化之后被收进左侧栏的深层页面仍然一步可达。支持 `Cmd/Ctrl + K` 唤起。
58
+ * 这样分组化之后被收进左侧栏的深层页面仍然一步可达。支持 `Cmd/Ctrl + F` 唤起。
58
59
  */
59
60
  export const SettingsSearch: React.FC = observer(() => {
60
61
  const { t } = useTranslation();
@@ -63,11 +64,15 @@ export const SettingsSearch: React.FC = observer(() => {
63
64
  const location = useLocation();
64
65
  const { token } = antdTheme.useToken();
65
66
  const headerColors = getSettingsHeaderColors(token);
67
+ const isACLReady = useACLCheckReady(app);
66
68
  const { recentItems, search } = useSettingsSearch();
67
69
  const [open, setOpen] = useState(false);
68
70
  const [keyword, setKeyword] = useState('');
69
71
  const [activeIndex, setActiveIndex] = useState(0);
70
72
  const inputRef = useRef<InputRef>(null);
73
+ const triggerRef = useRef<HTMLDivElement>(null);
74
+ const lastActiveElementRef = useRef<HTMLElement | null>(null);
75
+ const isPointerDownRef = useRef(false);
71
76
 
72
77
  // 登录 / 找回密码等免鉴权页面共用同一个 shell,这些页面上没有可搜索的配置项。
73
78
  const isAuthRoute = app.router.isSkippedAuthCheckRoute(location.pathname);
@@ -78,10 +83,33 @@ export const SettingsSearch: React.FC = observer(() => {
78
83
  setActiveIndex(0);
79
84
  }, [keyword]);
80
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
+
81
101
  const openPalette = useCallback(() => {
82
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();
83
111
  setOpen(true);
84
- }, []);
112
+ }, [open]);
85
113
 
86
114
  const go = useCallback(
87
115
  (item?: SettingsSearchItem) => {
@@ -104,29 +132,76 @@ export const SettingsSearch: React.FC = observer(() => {
104
132
  );
105
133
 
106
134
  useEffect(() => {
107
- if (isAuthRoute) {
135
+ if (isAuthRoute || !isACLReady) {
108
136
  return;
109
137
  }
110
138
 
111
139
  const onKeyDown = (event: KeyboardEvent) => {
112
- // 页面内组件(代码编辑器之类)可能自己就用 Cmd/Ctrl+K;它们 preventDefault 之后
140
+ // 页面内组件可能自己就用 Cmd/Ctrl+F;它们 preventDefault 之后
113
141
  // 这个 window 级监听器不该再抢一次。
114
142
  if (event.defaultPrevented) {
115
143
  return;
116
144
  }
117
145
 
118
- if (event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
146
+ const hasPlatformModifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
147
+
148
+ if (event.key?.toLowerCase() === 'f' && hasPlatformModifier && !event.altKey && !event.shiftKey) {
119
149
  event.preventDefault();
120
- openPalette();
150
+ if (!event.repeat) {
151
+ openPalette();
152
+ }
121
153
  }
122
154
  };
123
155
 
124
156
  window.addEventListener('keydown', onKeyDown);
125
157
  return () => window.removeEventListener('keydown', onKeyDown);
126
- }, [isAuthRoute, openPalette]);
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]);
127
195
 
128
196
  const onInputKeyDown = useCallback(
129
197
  (event: React.KeyboardEvent) => {
198
+ if (event.key === 'Escape') {
199
+ event.preventDefault();
200
+ event.stopPropagation();
201
+ setOpen(false);
202
+ return;
203
+ }
204
+
130
205
  if (event.key === 'ArrowDown') {
131
206
  event.preventDefault();
132
207
  setActiveIndex((index) => (results.length ? (index + 1) % results.length : 0));
@@ -147,13 +222,14 @@ export const SettingsSearch: React.FC = observer(() => {
147
222
  [activeIndex, go, results],
148
223
  );
149
224
 
150
- if (isAuthRoute) {
225
+ if (isAuthRoute || !isACLReady) {
151
226
  return null;
152
227
  }
153
228
 
154
229
  return (
155
230
  <>
156
231
  <div
232
+ ref={triggerRef}
157
233
  className={triggerClassName}
158
234
  role="button"
159
235
  tabIndex={0}
@@ -161,6 +237,14 @@ export const SettingsSearch: React.FC = observer(() => {
161
237
  style={{ color: headerColors.text }}
162
238
  onClick={openPalette}
163
239
  onKeyDown={(event) => {
240
+ if (event.key === 'Escape' && open) {
241
+ event.preventDefault();
242
+ event.stopPropagation();
243
+ setOpen(false);
244
+ restoreLastActiveElement();
245
+ return;
246
+ }
247
+
164
248
  if (event.key === 'Enter' || event.key === ' ') {
165
249
  event.preventDefault();
166
250
  openPalette();
@@ -182,9 +266,17 @@ export const SettingsSearch: React.FC = observer(() => {
182
266
  footer={null}
183
267
  closable={false}
184
268
  destroyOnClose
269
+ focusTriggerAfterClose={false}
185
270
  width={520}
186
271
  styles={{ body: { paddingTop: 4 } }}
187
- afterOpenChange={(opened) => opened && inputRef.current?.focus()}
272
+ afterOpenChange={(opened) => {
273
+ if (opened) {
274
+ inputRef.current?.focus();
275
+ return;
276
+ }
277
+
278
+ restoreLastActiveElement();
279
+ }}
188
280
  onCancel={() => setOpen(false)}
189
281
  >
190
282
  <Input
@@ -196,6 +288,19 @@ export const SettingsSearch: React.FC = observer(() => {
196
288
  size="large"
197
289
  value={keyword}
198
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
+ }}
199
304
  onKeyDown={onInputKeyDown}
200
305
  />
201
306
  <div style={{ borderTop: `${token.lineWidth}px solid ${token.colorSplit}`, margin: '8px -24px 0' }} />
@@ -18,6 +18,7 @@ import {
18
18
  } from '../flow/models/topbar/UserCenterTopbarActionModel';
19
19
  import { useApp } from '../hooks/useApp';
20
20
  import { useCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
21
+ import { addCustomAlgorithmToTheme } from '../theme/customAlgorithm';
21
22
  import { SettingsBrand } from './SettingsBrand';
22
23
  import { SettingsGroupNav } from './SettingsGroupNav';
23
24
  import { SettingsSearch } from './SettingsSearch';
@@ -79,25 +80,41 @@ export const SettingsShell: FC = ({ children }) => {
79
80
  const { token } = antdTheme.useToken();
80
81
  // 设置中心的外观由主题编辑器里那条「简约」主题记录约束;读不到才用代码里的中性配色兜底。
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
+ );
82
95
  // 顶栏和补丁样式要按**设置中心自己这套主题**算色,不能直接用上面那个 `token`——
83
96
  // 这个组件在自己的 ConfigProvider 外面,`token` 是业务端主题的。用它的话,业务端一切暗黑,
84
97
  // 顶栏就跟着变黑,而内容区还是简约的白,一半黑一半白。
85
98
  const settingsToken = useMemo(
86
- () => (storedThemeConfig ? antdTheme.getDesignToken(storedThemeConfig) : token),
87
- [storedThemeConfig, token],
99
+ () => (normalizedStoredThemeConfig ? antdTheme.getDesignToken(normalizedStoredThemeConfig) : token),
100
+ [normalizedStoredThemeConfig, token],
88
101
  );
89
102
  const settingsShellTheme = useMemo<ThemeConfig>(
90
103
  () =>
91
- storedThemeConfig ? withSettingsHeaderTheme(storedThemeConfig, settingsToken) : buildSettingsNeutralTheme(token),
92
- [settingsToken, storedThemeConfig, token],
104
+ normalizedStoredThemeConfig
105
+ ? withSettingsHeaderTheme(normalizedStoredThemeConfig, settingsToken)
106
+ : buildSettingsNeutralTheme(token),
107
+ [normalizedStoredThemeConfig, settingsToken, token],
93
108
  );
94
109
  const headerColors = useMemo(() => getSettingsHeaderColors(settingsToken), [settingsToken]);
95
110
  const settingsGlobalCss = useMemo(() => buildSettingsGlobalCss(settingsToken), [settingsToken]);
96
111
  const authStatus = useCurrentUserAuthStatus(app);
97
- const isAuthenticationRoute = (app.router.matchRoutes(location.pathname) || []).some((match) => {
112
+ const routeMatches = app.router.matchRoutes(location.pathname) || [];
113
+ const isAuthenticationRoute = routeMatches.some((match) => {
98
114
  const routeId = match.route.id;
99
115
  return routeId === 'auth' || routeId?.startsWith('auth.') || routeId === '2fa' || routeId?.startsWith('2fa.');
100
116
  });
117
+ const shouldHideHeader = routeMatches.some((match) => match.route.id === 'settingsDetails.idpOAuth.device');
101
118
 
102
119
  if (isAuthenticationRoute) {
103
120
  // 登录 / 二次验证页也要套设置中心自己的主题,否则它跟的是业务端主题:
@@ -114,7 +131,7 @@ export const SettingsShell: FC = ({ children }) => {
114
131
  );
115
132
  }
116
133
 
117
- const shouldShowHeader = authStatus === 'authenticated';
134
+ const shouldShowHeader = authStatus === 'authenticated' && !shouldHideHeader;
118
135
  const hasUserCenterModel = Boolean(app.flowEngine.getModelClass('UserCenterTopbarActionModel'));
119
136
  const userCenter = hasUserCenterModel
120
137
  ? app.flowEngine.getModel<UserCenterTopbarActionModel>(`topbar-action-${USER_CENTER_ACTION_ID}`) ||
@@ -26,7 +26,6 @@ type SettingsRuntimeApp = Pick<BaseApplication<any>, 'name'> & {
26
26
  const LEGACY_EMAIL_OAUTH_PATH = '/admin/settings/mail/oauth2';
27
27
 
28
28
  const ROUTE_MAPPINGS = [
29
- { from: '/admin/ai/knowledge-base/detail', to: '/settings/ai/knowledge-base/detail' },
30
29
  { from: '/admin/workflow/executions', to: '/settings/workflow/executions' },
31
30
  { from: '/admin/workflow/workflows', to: '/settings/workflow/workflows' },
32
31
  { from: '/admin/settings', to: '/settings' },
@@ -236,6 +236,14 @@ export function withSettingsHeaderTheme(theme: ThemeConfig, token: GlobalToken):
236
236
  };
237
237
  }
238
238
 
239
+ /**
240
+ * Height of one entry in the top bar dropdown.
241
+ *
242
+ * Taken from the user center dropdown (`nb-user-center-item`, 30px) so that the
243
+ * two dropdowns hanging off the same top bar line up.
244
+ */
245
+ const SETTINGS_MENU_ITEM_HEIGHT = 30;
246
+
239
247
  /**
240
248
  * 设置中心的全局补丁样式。
241
249
  *
@@ -251,20 +259,26 @@ export function withSettingsHeaderTheme(theme: ThemeConfig, token: GlobalToken):
251
259
  */
252
260
  export function buildSettingsGlobalCss(token: GlobalToken): string {
253
261
  return `
262
+ /* The rules below deliberately drop the .nb-settings-shell prefix: antd renders
263
+ drawers, modals and dropdowns into portals under body, where a shell-scoped
264
+ selector never matches — that is how the edit link inside the collection drawer
265
+ kept antd's default blue. This stylesheet is only injected by the standalone
266
+ settings SPA, so unscoped selectors cannot reach the business side. */
267
+
254
268
  /* 最外层 body 的底色来自业务端主题(暗黑下是 #141414)。内容区一般把它整块盖住,
255
269
  但内容不满屏、或者滚动橡皮筋回弹时会漏出来,所以这里一并接管。
256
270
  设置中心是独立入口的单页应用,这条全局规则不会影响业务端。 */
257
271
  body {
258
272
  background: ${token.colorBgLayout};
259
273
  }
260
- .nb-settings-shell a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn):not([class*="ant-"]) {
274
+ a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn):not([class*="ant-"]) {
261
275
  color: ${token.colorTextSecondary};
262
276
  }
263
- .nb-settings-shell a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn):not([class*="ant-"]):hover {
277
+ a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn):not([class*="ant-"]):hover {
264
278
  color: ${token.colorText};
265
279
  }
266
- .nb-settings-shell a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn),
267
- .nb-settings-shell .ant-btn-link:not(:disabled) {
280
+ a:not(.ant-btn):not(.ant-menu-item):not(.ant-tabs-tab-btn),
281
+ .ant-btn-link:not(:disabled) {
268
282
  text-decoration: underline;
269
283
  text-underline-offset: 2px;
270
284
  }
@@ -284,18 +298,72 @@ body {
284
298
  .nb-settings-shell .ant-layout-sider .ant-menu-submenu-title::after {
285
299
  display: none !important;
286
300
  }
287
- /* 未启用态保持 antd 默认的实心灰滑轨:之前做成白底描边太淡,和「禁用」几乎看不出差别。
288
- 禁用态不用整体压暗(插件管理器里一整屏都是不可操作的内置插件,压狠了像坏了),
289
- 而是把滑轨调到跟「未启用」同一档灰、滑块的小白圆点也压成浅灰——
290
- 整体退成一片同色系的浅灰,跟纯黑的「开着且能关」拉开距离。 */
291
- .nb-settings-shell .ant-switch-disabled {
292
- opacity: 1;
301
+ /* Entry height matches the other dropdown on the same top bar (the user center,
302
+ whose nb-user-center-item is 30px). antd sizes menu entries with
303
+ controlHeightLG (35px under compact), which is the height of a single clickable
304
+ control; this dropdown is a list of a dozen links and reads loose at 35.
305
+
306
+ Scoped to entries inside the popup only: the horizontal top bar menu shares the
307
+ same Menu tokens, so changing tokens would eat its horizontal padding too. The
308
+ selector needs three classes; two do not outweigh antd's own rule. */
309
+ .ant-menu-submenu-popup .ant-menu.ant-menu-sub.ant-menu-vertical {
310
+ /* With the 4px margin between entries gone, the first and last would sit flush
311
+ against the popup edge, so the container gives the padding back. */
312
+ padding-block: ${token.paddingXXS}px;
293
313
  }
294
- .nb-settings-shell .ant-switch-disabled.ant-switch-checked {
295
- background: ${token.colorTextQuaternary};
314
+ .ant-menu-submenu-popup .ant-menu-sub > .ant-menu-item,
315
+ .ant-menu-submenu-popup .ant-menu-sub > .ant-menu-submenu > .ant-menu-submenu-title {
316
+ height: ${SETTINGS_MENU_ITEM_HEIGHT}px;
317
+ line-height: ${SETTINGS_MENU_ITEM_HEIGHT}px;
318
+ margin-block: 0;
296
319
  }
297
- .nb-settings-shell .ant-switch-disabled .ant-switch-handle::before {
298
- background: ${token.colorBorderSecondary};
320
+
321
+ /* Info alerts: with the primary color collapsed to black and white, their derived
322
+ background lands on rgba(0,0,0,0.02) — faint enough to read as empty space.
323
+ Rather than reaching for color (which would drag the palette back in), the shape
324
+ carries the weight: a 4px bar on the left, a firmer background and border, a
325
+ bold title and a larger icon. Warning / error / success keep their semantic
326
+ colors and are left alone. */
327
+ .ant-alert-info {
328
+ background: ${token.colorFillSecondary};
329
+ border: ${token.lineWidth}px solid ${token.colorBorder};
330
+ border-inline-start: 4px solid ${token.colorText};
331
+ padding-block: ${token.paddingSM}px;
332
+ }
333
+ .ant-alert-info .ant-alert-icon,
334
+ .ant-alert-info .ant-alert-message {
335
+ color: ${token.colorText};
336
+ }
337
+ .ant-alert-info .ant-alert-icon {
338
+ font-size: ${token.fontSizeLG}px;
339
+ }
340
+ .ant-alert-info .ant-alert-message {
341
+ font-weight: 600;
342
+ }
343
+ .ant-alert-info .ant-alert-description {
344
+ color: ${token.colorTextSecondary};
345
+ }
346
+ /* Three switch shades: off is light grey (colorFill, 0.15), disabled is dark grey
347
+ (colorTextTertiary, 0.45), on is black. Disabled being darker than off is
348
+ deliberate — here "darker" reads as "pinned", not "stronger" — and one light
349
+ against one dark is told apart at a glance, without comparing subtle
350
+ brightness steps. Disabled is not dimmed as a whole: the plugin manager shows
351
+ over a hundred built-in plugins in that state, and dimming makes the page look
352
+ broken. */
353
+ .ant-switch {
354
+ background: ${token.colorFill};
355
+ }
356
+ .ant-switch.ant-switch-checked {
357
+ background: ${token.colorText};
358
+ }
359
+ /* This selector has to outweigh antd's own .ant-switch.ant-switch-disabled (a
360
+ single class no longer does, now that the shell prefix is gone), otherwise its
361
+ opacity: 0.65 survives and washes the dark grey out onto the off state, where
362
+ the two read as the same color. */
363
+ .ant-switch.ant-switch-disabled,
364
+ .ant-switch.ant-switch-disabled.ant-switch-checked {
365
+ background: ${token.colorTextTertiary};
366
+ opacity: 1;
299
367
  }
300
368
  `;
301
369
  }