@nocobase/client-v2 2.2.0-beta.9 → 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 (208) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +1 -0
  3. package/es/acl/ACLProvider.d.ts +10 -0
  4. package/es/authRedirect.d.ts +5 -0
  5. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  6. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  7. package/es/components/AttachmentUpload.d.ts +51 -0
  8. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  9. package/es/components/category-tabs/index.d.ts +9 -0
  10. package/es/components/form/ScanInput/useCodeScanner.d.ts +3 -2
  11. package/es/components/form/TypedVariableInput.d.ts +31 -5
  12. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  13. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  14. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  15. package/es/components/form/filter/index.d.ts +2 -0
  16. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  17. package/es/components/index.d.ts +2 -0
  18. package/es/flow/actions/index.d.ts +1 -1
  19. package/es/flow/actions/linkageRules.d.ts +3 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  21. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  22. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  23. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  24. package/es/flow/components/FlowRoute.d.ts +2 -2
  25. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  26. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  27. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  28. package/es/flow/index.d.ts +1 -0
  29. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  30. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  31. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  32. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  33. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  34. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  35. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  36. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  37. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  38. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  39. package/es/flow-compat/routeTypes.d.ts +1 -0
  40. package/es/index.d.ts +8 -1
  41. package/es/index.mjs +190 -141
  42. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  43. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  44. package/es/settings-app/SettingsApplication.d.ts +18 -0
  45. package/es/settings-app/SettingsBrand.d.ts +16 -0
  46. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  47. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  48. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  49. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  50. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  51. package/es/settings-app/SettingsSearch.d.ts +17 -0
  52. package/es/settings-app/SettingsShell.d.ts +10 -0
  53. package/es/settings-app/isSettingsApp.d.ts +18 -0
  54. package/es/settings-app/runtimePaths.d.ts +18 -0
  55. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  56. package/es/settings-app/settingsTheme.d.ts +87 -0
  57. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  58. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  59. package/es/settings-center/groups.d.ts +66 -0
  60. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  61. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  62. package/es/settings-center/utils.d.ts +24 -0
  63. package/es/theme/index.d.ts +1 -0
  64. package/es/theme/itemActive.d.ts +24 -0
  65. package/es/theme/type.d.ts +9 -0
  66. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  67. package/es/utils/index.d.ts +1 -0
  68. package/lib/index.js +195 -146
  69. package/lib/locale/languageCodes.js +3 -1
  70. package/package.json +9 -7
  71. package/src/APIClient.ts +1 -10
  72. package/src/Application.tsx +4 -0
  73. package/src/BaseApplication.tsx +11 -6
  74. package/src/__tests__/app.test.tsx +73 -0
  75. package/src/__tests__/authRedirect.test.ts +137 -0
  76. package/src/__tests__/browserChecker.test.ts +185 -6
  77. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  78. package/src/__tests__/mockSettingsApplication.ts +29 -0
  79. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +79 -10
  80. package/src/__tests__/plugin-manager.test.tsx +7 -6
  81. package/src/__tests__/settings-application.test.ts +164 -0
  82. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  83. package/src/__tests__/settings-center.test.tsx +38 -37
  84. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  85. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  86. package/src/__tests__/settings-shell.test.tsx +200 -0
  87. package/src/acl/ACLProvider.tsx +21 -0
  88. package/src/authRedirect.ts +72 -3
  89. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  90. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  91. package/src/collection-manager/field-validation.ts +1 -1
  92. package/src/components/AttachmentUpload.tsx +275 -0
  93. package/src/components/README.md +7 -1
  94. package/src/components/README.zh-CN.md +6 -1
  95. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  96. package/src/components/category-tabs/index.ts +10 -0
  97. package/src/components/form/JsonTextArea.tsx +4 -0
  98. package/src/components/form/ScanInput/CodeScanner.tsx +79 -35
  99. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  100. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +284 -11
  101. package/src/components/form/ScanInput/useCodeScanner.ts +332 -24
  102. package/src/components/form/TypedVariableInput.tsx +452 -101
  103. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  104. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  105. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  106. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  107. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  108. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  109. package/src/components/form/filter/index.ts +2 -0
  110. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  111. package/src/components/index.ts +2 -0
  112. package/src/flow/FlowPage.tsx +9 -1
  113. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  114. package/src/flow/__tests__/FlowRoute.test.tsx +128 -10
  115. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  116. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  117. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  118. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  119. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  120. package/src/flow/actions/index.ts +2 -0
  121. package/src/flow/actions/linkageRules.tsx +111 -16
  122. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  123. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -57
  124. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  125. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  126. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  127. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  128. package/src/flow/common/Markdown/style.ts +4 -0
  129. package/src/flow/components/BlockItemCard.tsx +2 -2
  130. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  131. package/src/flow/components/FieldAssignValueInput.tsx +91 -20
  132. package/src/flow/components/FlowRoute.tsx +78 -32
  133. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  134. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  135. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  136. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  137. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  138. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  139. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  140. package/src/flow/index.ts +3 -0
  141. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  142. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  143. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  144. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  145. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  146. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  147. package/src/flow/models/base/PageModel/PageTabModel.tsx +249 -18
  148. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  149. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  150. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  151. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +1075 -7
  152. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  153. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  154. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  155. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +28 -3
  156. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  157. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  158. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +66 -4
  159. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  160. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  161. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  162. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  163. package/src/flow/models/blocks/table/TableBlockModel.tsx +4 -2
  164. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  165. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  166. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  167. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +2 -1
  168. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  169. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  170. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  171. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  172. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  173. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  174. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  175. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
  176. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  177. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  178. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  179. package/src/flow-compat/routeTypes.ts +1 -0
  180. package/src/index.ts +14 -1
  181. package/src/locale/languageCodes.ts +3 -1
  182. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  183. package/src/nocobase-buildin-plugin/index.tsx +110 -70
  184. package/src/settings-app/SettingsApplication.ts +31 -0
  185. package/src/settings-app/SettingsBrand.tsx +108 -0
  186. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  187. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  188. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  189. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  190. package/src/settings-app/SettingsRouterManager.ts +92 -0
  191. package/src/settings-app/SettingsSearch.tsx +238 -0
  192. package/src/settings-app/SettingsShell.tsx +167 -0
  193. package/src/settings-app/isSettingsApp.ts +21 -0
  194. package/src/settings-app/runtimePaths.ts +104 -0
  195. package/src/settings-app/settingsDocumentPath.ts +66 -0
  196. package/src/settings-app/settingsTheme.ts +301 -0
  197. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  198. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  199. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  200. package/src/settings-center/groups.ts +108 -0
  201. package/src/settings-center/useSettingsGroups.ts +105 -0
  202. package/src/settings-center/useSettingsSearch.ts +173 -0
  203. package/src/settings-center/utils.tsx +50 -1
  204. package/src/theme/index.tsx +1 -0
  205. package/src/theme/itemActive.ts +31 -0
  206. package/src/theme/type.ts +10 -0
  207. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  208. package/src/utils/index.tsx +1 -0
@@ -7,29 +7,25 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { ApiOutlined } from '@ant-design/icons';
11
10
  import { PageHeader } from '@ant-design/pro-layout';
12
11
  import { css } from '@emotion/css';
13
12
  import { FlowModelRenderer, useFlowEngine } from '@nocobase/flow-engine';
14
- import { Layout, Menu, Result, theme } from 'antd';
13
+ import { Layout, Menu, Result, Tabs, theme } from 'antd';
15
14
  import React, { useEffect, useMemo, useRef } from 'react';
16
15
  import { useTranslation } from 'react-i18next';
17
- import { Navigate, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
18
- import { useACLRoleContext } from '../acl';
19
- import { ADMIN_SETTINGS_PATH, type PluginSettingsPageType } from '../PluginSettingsManager';
16
+ import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
17
+ import type { PluginSettingsPageType } from '../PluginSettingsManager';
20
18
  import { useApp } from '../hooks/useApp';
21
19
  import { AdminSettingsLayoutModel } from './AdminSettingsLayoutModel';
20
+ import { useSettingsGroups } from './useSettingsGroups';
22
21
  import {
23
22
  ADMIN_SETTINGS_LAYOUT_MODEL_UID,
24
23
  createSettingsPathMap,
25
- filterRenderableSettings,
26
- filterVisibleSettings,
24
+ findSettingsByName,
27
25
  getDefaultSettingsPath,
28
- getMenuItems,
26
+ getSidebarMenuItems,
27
+ getSidebarSelectedKey,
29
28
  matchSettingsRoute,
30
- PLUGIN_MANAGER_SETTING_NAME,
31
- replaceRouteParams,
32
- sortTopLevelSettings,
33
29
  } from './utils';
34
30
 
35
31
  function SettingsEmpty(props: { type: 'home' | 'route' }) {
@@ -60,57 +56,27 @@ function SettingsEmpty(props: { type: 'home' | 'route' }) {
60
56
  /**
61
57
  * `client-v2` 的 settings 页面壳实现。
62
58
  *
63
- * 该组件负责左侧菜单、顶部 tabs、默认落点和两类空态,
64
- * 页面内容本身继续由各个 settings route 的 `<Outlet />` 渲染。
59
+ * 一级分组铺在顶栏(见 `SettingsGroupNav`),这里负责当前分组下的二三级嵌套菜单、
60
+ * 默认落点和两类空态,页面内容本身继续由各个 settings route 的 `<Outlet />` 渲染。
65
61
  */
66
62
  export const InternalAdminSettingsLayout = () => {
67
63
  const app = useApp();
68
64
  const navigate = useNavigate();
69
65
  const location = useLocation();
70
- const params = useParams();
71
66
  const { token } = theme.useToken();
72
- const { t } = useTranslation();
73
- const { snippets = [] } = useACLRoleContext();
67
+ const {
68
+ activeGroupSettings,
69
+ allSettings,
70
+ currentSetting,
71
+ currentTopLevelSetting,
72
+ visibleSettings: allVisibleSettings,
73
+ } = useSettingsGroups();
74
74
 
75
- const allSettings = useMemo(
76
- () => filterRenderableSettings(app.pluginSettingsManager.getList(false) as PluginSettingsPageType[]),
77
- [app.pluginSettingsManager],
78
- );
79
- const visibleSettings = useMemo(
80
- () =>
81
- filterVisibleSettings(
82
- filterRenderableSettings(app.pluginSettingsManager.getList(true) as PluginSettingsPageType[]),
83
- ),
84
- [app.pluginSettingsManager],
85
- );
86
- const pluginManagerSetting = useMemo(
87
- () => visibleSettings.find((item) => item.name === PLUGIN_MANAGER_SETTING_NAME) || null,
88
- [visibleSettings],
89
- );
90
- const normalSettings = useMemo(
91
- () => sortTopLevelSettings(visibleSettings.filter((item) => item.name !== PLUGIN_MANAGER_SETTING_NAME)),
92
- [visibleSettings],
93
- );
94
- const allVisibleSettings = useMemo(
95
- () => (pluginManagerSetting ? [pluginManagerSetting, ...normalSettings] : normalSettings),
96
- [normalSettings, pluginManagerSetting],
97
- );
98
- const registeredSettingsMapByPath = useMemo(() => createSettingsPathMap(allSettings), [allSettings]);
99
75
  const visibleSettingsMapByPath = useMemo(() => createSettingsPathMap(allVisibleSettings), [allVisibleSettings]);
100
- const currentSetting = useMemo(
101
- () => matchSettingsRoute(registeredSettingsMapByPath, location.pathname),
102
- [location.pathname, registeredSettingsMapByPath],
103
- );
104
76
  const currentVisibleSetting = useMemo(
105
77
  () => matchSettingsRoute(visibleSettingsMapByPath, location.pathname),
106
78
  [location.pathname, visibleSettingsMapByPath],
107
79
  );
108
- const currentTopLevelSetting = useMemo(() => {
109
- if (!currentSetting) {
110
- return null;
111
- }
112
- return allSettings.find((item) => item.name === currentSetting.topLevelName) || currentSetting;
113
- }, [allSettings, currentSetting]);
114
80
  const currentVisibleTopLevelSetting = useMemo(() => {
115
81
  if (!currentSetting) {
116
82
  return null;
@@ -118,10 +84,42 @@ export const InternalAdminSettingsLayout = () => {
118
84
  return allVisibleSettings.find((item) => item.name === currentSetting.topLevelName) || null;
119
85
  }, [allVisibleSettings, currentSetting]);
120
86
  const defaultSettingsPath = useMemo(() => getDefaultSettingsPath(allVisibleSettings), [allVisibleSettings]);
121
- const currentVisibleTabs = useMemo(() => {
122
- return (currentVisibleTopLevelSetting?.children || []).filter((item) => !item.hidden) as PluginSettingsPageType[];
123
- }, [currentVisibleTopLevelSetting?.children]);
124
- const shouldShowTabs = currentVisibleTabs.length > 1 && currentVisibleTopLevelSetting?.showTabs !== false;
87
+ const settingsRootPath = app.pluginSettingsManager.getRoutePath('');
88
+ const settingsRootPathWithoutTrailingSlash = settingsRootPath.replace(/\/$/, '');
89
+
90
+ const sidebarMenus = useMemo(() => getSidebarMenuItems(activeGroupSettings), [activeGroupSettings]);
91
+ // 分组里只有一个顶级配置项时不铺左栏:那样整条侧栏只是把顶栏那一项重复一遍。
92
+ // 它自己的下级(用户和权限的 用户 / 角色和权限 / 同步,AI 员工的几个页面)改用页头下的 Tab。
93
+ const shouldShowSidebar = activeGroupSettings.length > 1;
94
+ // 子页面一律走页头下的 Tab:左栏只表达「哪个模块」,模块内部的分页交给 Tab,
95
+ // 和 v1 设置中心保持一致。
96
+ const pageTabs = useMemo(() => {
97
+ const children = (currentVisibleTopLevelSetting?.children || []).filter(
98
+ (item) => !item.hidden && !item.link && item.path,
99
+ );
100
+ return children.length > 1 ? children.map((item) => ({ key: item.path, label: item.label ?? item.title })) : [];
101
+ }, [currentVisibleTopLevelSetting]);
102
+ const activeTabKey = useMemo(() => {
103
+ if (!pageTabs.length) {
104
+ return undefined;
105
+ }
106
+ const matched = pageTabs.find(
107
+ (tab) => location.pathname === tab.key || location.pathname.startsWith(`${tab.key}/`),
108
+ );
109
+ return matched?.key ?? pageTabs[0]?.key;
110
+ }, [location.pathname, pageTabs]);
111
+ // 命中的可能是被折叠掉的子项,要换算成左栏里真实存在的那一级,否则整个左栏都不高亮。
112
+ const selectedMenuKey = useMemo(
113
+ () =>
114
+ getSidebarSelectedKey(activeGroupSettings, currentVisibleSetting?.name) ||
115
+ getSidebarSelectedKey(activeGroupSettings, currentVisibleTopLevelSetting?.name),
116
+ [activeGroupSettings, currentVisibleSetting?.name, currentVisibleTopLevelSetting?.name],
117
+ );
118
+ // 页头只需要补一句「当前在哪个子页」。
119
+ const pageSubTitle =
120
+ currentVisibleSetting && currentVisibleSetting.title !== currentTopLevelSetting?.title
121
+ ? currentVisibleSetting.title
122
+ : undefined;
125
123
 
126
124
  useEffect(() => {
127
125
  const nextTitle =
@@ -134,33 +132,10 @@ export const InternalAdminSettingsLayout = () => {
134
132
  }
135
133
  }, [currentTopLevelSetting]);
136
134
 
137
- const sidebarMenus = useMemo(() => {
138
- const items: any[] = [];
139
-
140
- if (pluginManagerSetting && snippets.includes('pm')) {
141
- items.push({
142
- key: pluginManagerSetting.name,
143
- icon: pluginManagerSetting.icon || <ApiOutlined />,
144
- label: pluginManagerSetting.label || t('Plugin manager'),
145
- });
146
- }
147
-
148
- if (items.length && normalSettings.length) {
149
- items.push({ type: 'divider' });
150
- }
151
-
152
- const normalMenuItems =
153
- getMenuItems(normalSettings.map((item) => ({ ...item, children: undefined }) as PluginSettingsPageType)) || [];
154
-
155
- items.push(...normalMenuItems);
156
-
157
- return items;
158
- }, [normalSettings, pluginManagerSetting, snippets, t]);
159
-
160
135
  const shouldRedirectToDefault =
161
- location.pathname === ADMIN_SETTINGS_PATH ||
162
- location.pathname === ADMIN_SETTINGS_PATH.replace(/\/$/, '') ||
163
- location.pathname === `${ADMIN_SETTINGS_PATH}index`;
136
+ location.pathname === settingsRootPath ||
137
+ location.pathname === settingsRootPathWithoutTrailingSlash ||
138
+ location.pathname === `${settingsRootPath}index`;
164
139
 
165
140
  if (shouldRedirectToDefault && defaultSettingsPath) {
166
141
  return <Navigate replace to={defaultSettingsPath} />;
@@ -204,41 +179,43 @@ export const InternalAdminSettingsLayout = () => {
204
179
  overflow: 'hidden',
205
180
  }}
206
181
  >
207
- <Layout.Sider
208
- width={200}
209
- style={{
210
- background: token.colorBgContainer,
211
- borderInlineEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
212
- minHeight: 0,
213
- overflowY: 'auto',
214
- overflowX: 'hidden',
215
- }}
216
- >
217
- <Menu
218
- selectedKeys={currentVisibleTopLevelSetting?.name ? [currentVisibleTopLevelSetting.name] : []}
219
- style={{ height: '100%', borderInlineEnd: 'none' }}
220
- onClick={({ key }) => {
221
- const topLevelSetting = allVisibleSettings.find((item) => item.name === key);
222
- if (!topLevelSetting) {
223
- return;
224
- }
225
-
226
- if (topLevelSetting.link) {
227
- window.open(topLevelSetting.link, '_blank', 'noopener,noreferrer');
228
- return;
229
- }
230
-
231
- const firstPath = topLevelSetting.children?.length
232
- ? getDefaultSettingsPath(topLevelSetting.children)
233
- : topLevelSetting.path;
234
-
235
- if (firstPath) {
236
- navigate(firstPath);
237
- }
182
+ {shouldShowSidebar ? (
183
+ <Layout.Sider
184
+ width={200}
185
+ style={{
186
+ background: token.colorBgContainer,
187
+ borderInlineEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
188
+ minHeight: 0,
189
+ overflowY: 'auto',
190
+ overflowX: 'hidden',
238
191
  }}
239
- items={sidebarMenus}
240
- />
241
- </Layout.Sider>
192
+ >
193
+ <Menu
194
+ mode="inline"
195
+ inlineIndent={16}
196
+ selectedKeys={selectedMenuKey ? [selectedMenuKey] : []}
197
+ style={{ height: '100%', borderInlineEnd: 'none' }}
198
+ onClick={({ key }) => {
199
+ const setting = findSettingsByName(activeGroupSettings, String(key));
200
+ if (!setting) {
201
+ return;
202
+ }
203
+
204
+ if (setting.link) {
205
+ window.open(setting.link, '_blank', 'noopener,noreferrer');
206
+ return;
207
+ }
208
+
209
+ const targetPath = setting.children?.length ? getDefaultSettingsPath(setting.children) : setting.path;
210
+
211
+ if (targetPath && targetPath !== location.pathname) {
212
+ navigate(targetPath);
213
+ }
214
+ }}
215
+ items={sidebarMenus}
216
+ />
217
+ </Layout.Sider>
218
+ ) : null}
242
219
  <Layout.Content
243
220
  style={{
244
221
  background: token.colorBgLayout,
@@ -250,41 +227,45 @@ export const InternalAdminSettingsLayout = () => {
250
227
  overflow: 'hidden',
251
228
  }}
252
229
  >
253
- <PageHeader
254
- ghost={false}
255
- title={currentTopLevelSetting?.title}
256
- style={{
257
- background: token.colorBgContainer,
258
- borderBlockEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
259
- paddingBottom: shouldShowTabs ? 0 : token.padding,
260
- }}
261
- footer={
262
- shouldShowTabs ? (
263
- <Menu
264
- mode="horizontal"
265
- selectedKeys={[currentVisibleSetting?.name || currentSetting?.name]}
266
- items={getMenuItems(currentVisibleTabs)}
267
- onClick={({ key }) => {
268
- const targetPath = replaceRouteParams(app.pluginSettingsManager.getRoutePath(String(key)), params);
269
- if (location.pathname !== targetPath) {
270
- navigate(targetPath);
271
- }
272
- }}
273
- style={{ marginLeft: -token.marginSM, borderBottom: 'none' }}
274
- />
275
- ) : null
276
- }
277
- />
230
+ {/*
231
+ 页头放在滚动容器**内部**:钉在外面会让页面出现嵌套滚动(窗口一条、内容区一条),
232
+ 插件页面自己再带滚动区时会套成两三层,而且长页面白白少掉一条页头的高度。
233
+ */}
278
234
  <div
279
235
  style={{
280
236
  flex: 1,
281
237
  minHeight: 0,
282
238
  boxSizing: 'border-box',
283
239
  overflow: 'auto',
284
- padding: token.paddingLG,
285
240
  }}
286
241
  >
287
- <Outlet />
242
+ <PageHeader
243
+ ghost={false}
244
+ title={currentTopLevelSetting?.title}
245
+ subTitle={pageTabs.length ? undefined : pageSubTitle}
246
+ footer={
247
+ pageTabs.length ? (
248
+ <Tabs
249
+ activeKey={activeTabKey}
250
+ items={pageTabs}
251
+ tabBarStyle={{ marginBottom: 0 }}
252
+ onChange={(key) => {
253
+ if (key !== location.pathname) {
254
+ navigate(key);
255
+ }
256
+ }}
257
+ />
258
+ ) : undefined
259
+ }
260
+ style={{
261
+ background: token.colorBgContainer,
262
+ borderBlockEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
263
+ paddingBottom: pageTabs.length ? 0 : token.padding,
264
+ }}
265
+ />
266
+ <div style={{ padding: token.paddingLG }}>
267
+ <Outlet />
268
+ </div>
288
269
  </div>
289
270
  </Layout.Content>
290
271
  </Layout>
@@ -7,29 +7,14 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { UploadOutlined } from '@ant-design/icons';
11
- import { Alert, Button, Form, Input, Select, Space, Upload, message, theme } from 'antd';
12
- import type { RcFile, UploadRequestOption } from 'rc-upload/lib/interface';
13
- import mime from 'mime';
14
- import match from 'mime-match';
10
+ import { Alert, App, Button, Form, Input, Select, theme } from 'antd';
15
11
  import React, { useEffect, useMemo, useState } from 'react';
16
12
  import { useTranslation } from 'react-i18next';
13
+ import { AttachmentUpload } from '../components/AttachmentUpload';
17
14
  import languageCodes from '../locale/languageCodes';
18
15
  import { useApp } from '../hooks/useApp';
19
16
  import { useSystemSettings } from '../flow/system-settings';
20
17
 
21
- type UploadedAttachment = {
22
- id?: number;
23
- title?: string;
24
- filename?: string;
25
- url?: string;
26
- };
27
-
28
- type AttachmentStorageRules = {
29
- size?: number;
30
- mimetype?: string | string[];
31
- };
32
-
33
18
  /**
34
19
  * `System settings` 的最小 v2 表单页面。
35
20
  *
@@ -39,14 +24,11 @@ type AttachmentStorageRules = {
39
24
  export const SystemSettingsPage = () => {
40
25
  const app = useApp();
41
26
  const { token } = theme.useToken();
27
+ const { message } = App.useApp();
42
28
  const { t } = useTranslation();
43
29
  const { data, loading, error, mutate } = useSystemSettings();
44
30
  const [form] = Form.useForm();
45
31
  const [submitting, setSubmitting] = useState(false);
46
- const [uploading, setUploading] = useState(false);
47
- const [storageRules, setStorageRules] = useState<AttachmentStorageRules | null>(null);
48
- const logo = Form.useWatch<UploadedAttachment>('logo', form);
49
-
50
32
  useEffect(() => {
51
33
  const settings = data?.data;
52
34
  if (!settings) {
@@ -60,45 +42,6 @@ export const SystemSettingsPage = () => {
60
42
  });
61
43
  }, [data?.data, form]);
62
44
 
63
- useEffect(() => {
64
- let mounted = true;
65
-
66
- const loadStorageRules = async () => {
67
- try {
68
- const attachmentsCollection = app.dataSourceManager?.getCollection?.('main', 'attachments');
69
- const storageName = attachmentsCollection?.getOption?.('storage');
70
-
71
- if (!storageName) {
72
- if (mounted) {
73
- setStorageRules(null);
74
- }
75
- return;
76
- }
77
-
78
- const response = await app.apiClient.request({
79
- url: `storages:getBasicInfo/${storageName}`,
80
- skipNotify: true,
81
- });
82
-
83
- if (!mounted) {
84
- return;
85
- }
86
-
87
- setStorageRules(response?.data?.data?.rules || null);
88
- } catch (error) {
89
- if (mounted) {
90
- setStorageRules(null);
91
- }
92
- }
93
- };
94
-
95
- void loadStorageRules();
96
-
97
- return () => {
98
- mounted = false;
99
- };
100
- }, [app]);
101
-
102
45
  const languageOptions = useMemo(() => {
103
46
  const currentValues = form.getFieldValue('enabledLanguages') || data?.data?.enabledLanguages || [];
104
47
  const supported = Array.from(new Set([...Object.keys(languageCodes), ...currentValues]));
@@ -109,78 +52,6 @@ export const SystemSettingsPage = () => {
109
52
  }));
110
53
  }, [data?.data?.enabledLanguages, form]);
111
54
 
112
- const accept = useMemo(() => {
113
- const value = storageRules?.mimetype;
114
- if (!value) {
115
- return undefined;
116
- }
117
- return Array.isArray(value) ? value.join(',') : value;
118
- }, [storageRules?.mimetype]);
119
-
120
- const beforeUpload = async (file: RcFile) => {
121
- const sizeLimit = storageRules?.size;
122
-
123
- if (typeof sizeLimit === 'number' && sizeLimit > 0 && file.size > sizeLimit) {
124
- message.error(t('File size exceeds the limit'));
125
- return Upload.LIST_IGNORE;
126
- }
127
-
128
- const mimetypeRule = storageRules?.mimetype;
129
- const normalizedRule = Array.isArray(mimetypeRule) ? mimetypeRule.join(',').trim() : `${mimetypeRule || ''}`.trim();
130
-
131
- if (!normalizedRule || normalizedRule === '*') {
132
- return file;
133
- }
134
-
135
- let targetFile = file;
136
- if (!targetFile.type) {
137
- const extname = targetFile.name?.match(/\.[^.]+$/)?.[0];
138
- if (extname) {
139
- targetFile = new File([targetFile], targetFile.name, {
140
- type: mime.getType(extname) || 'application/octet-stream',
141
- lastModified: targetFile.lastModified,
142
- }) as RcFile;
143
- }
144
- }
145
-
146
- const isAllowed = normalizedRule
147
- .split(',')
148
- .filter(Boolean)
149
- .some((rule) => match(targetFile.type)(rule));
150
-
151
- if (!isAllowed) {
152
- message.error(t('File type is not allowed'));
153
- return Upload.LIST_IGNORE;
154
- }
155
-
156
- return targetFile;
157
- };
158
-
159
- const handleLogoUpload = async (options: UploadRequestOption) => {
160
- const { file, onError, onSuccess } = options;
161
- setUploading(true);
162
-
163
- try {
164
- const formData = new FormData();
165
- formData.append('file', file as RcFile);
166
-
167
- const response = await app.apiClient.request({
168
- url: 'attachments:create',
169
- method: 'post',
170
- data: formData,
171
- });
172
-
173
- const nextLogo = response?.data?.data;
174
-
175
- form.setFieldValue('logo', nextLogo);
176
- onSuccess?.(response?.data, file as RcFile);
177
- } catch (error) {
178
- onError?.(error);
179
- } finally {
180
- setUploading(false);
181
- }
182
- };
183
-
184
55
  const handleSubmit = async () => {
185
56
  const values = await form.validateFields();
186
57
  const payload = {
@@ -244,44 +115,7 @@ export const SystemSettingsPage = () => {
244
115
  <Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
245
116
  </Form.Item>
246
117
  <Form.Item name="logo" label={t('Logo')}>
247
- <Space direction="vertical" size={token.marginSM}>
248
- {logo?.url ? (
249
- <img
250
- alt="system-logo"
251
- src={logo.url}
252
- style={{
253
- width: 96,
254
- height: 96,
255
- objectFit: 'contain',
256
- borderRadius: token.borderRadius,
257
- border: `${token.lineWidth}px solid ${token.colorBorder}`,
258
- background: token.colorBgLayout,
259
- padding: token.paddingXS,
260
- }}
261
- />
262
- ) : null}
263
- <Space>
264
- <Upload
265
- showUploadList={false}
266
- customRequest={handleLogoUpload}
267
- beforeUpload={beforeUpload}
268
- accept={accept}
269
- >
270
- <Button loading={uploading} icon={<UploadOutlined />}>
271
- {t('Upload')}
272
- </Button>
273
- </Upload>
274
- {logo ? (
275
- <Button
276
- onClick={() => {
277
- form.setFieldValue('logo', null);
278
- }}
279
- >
280
- {t('Delete')}
281
- </Button>
282
- ) : null}
283
- </Space>
284
- </Space>
118
+ <AttachmentUpload preview={{ width: 96, height: 96, fit: 'contain' }} />
285
119
  </Form.Item>
286
120
  <Form.Item name="enabledLanguages" label={t('Enabled languages')} rules={[{ type: 'array', min: 1 }]}>
287
121
  <Select mode="multiple" options={languageOptions} />
@@ -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 type { PluginSettingsPageType } from '../PluginSettingsManager';
11
+
12
+ /**
13
+ * Settings 顶层分组。
14
+ *
15
+ * 顶栏铺一级(分组),左侧栏铺该分组下的二级 / 三级菜单。
16
+ * 分组归属优先取配置项自身的 `group` 字段,其次落到内置映射表,最后兜底到 `system`,
17
+ * 因此第三方插件不改代码也能出现在设置中心里。
18
+ */
19
+ export type SettingsGroupKey = 'applications' | 'plugins' | 'data' | 'access' | 'automation' | 'ai' | 'system';
20
+
21
+ export type SettingsGroupDefinition = {
22
+ key: SettingsGroupKey;
23
+ /** 未翻译的分组标题,交由调用方过 i18n */
24
+ title: string;
25
+ /**
26
+ * 分组的门面配置项。
27
+ *
28
+ * 它会被排到该分组的第一位,并作为点击顶栏分组时的落点;
29
+ * 不存在(插件没装 / 无权限)时自然退回到分组内的第一项。
30
+ */
31
+ primary?: string;
32
+ };
33
+
34
+ /**
35
+ * 顶栏铺六个独立入口 + 一个「其他设置」兜底分组。
36
+ *
37
+ * 前六个各自只有一个配置项、且都是日常高频,直接当一级入口(标题取配置项自己的名字,
38
+ * 顺带继承插件的翻译);其余零散配置全部收进其他设置,靠左栏和悬浮下拉展开。
39
+ *
40
+ * 兜底分组不叫「系统设置」:它下面本来就有一个同名的配置项(站点标题、logo 那个),
41
+ * 分组和子项重名会让人以为点进去是同一个东西。
42
+ */
43
+ export const SETTINGS_GROUPS: SettingsGroupDefinition[] = [
44
+ { key: 'applications', title: 'Applications', primary: 'multi-portal' },
45
+ { key: 'plugins', title: 'Plugin manager', primary: 'plugin-manager' },
46
+ { key: 'data', title: 'Data sources', primary: 'data-source-manager' },
47
+ { key: 'access', title: 'Users & Permissions', primary: 'users-permissions' },
48
+ { key: 'automation', title: 'Workflow', primary: 'workflow' },
49
+ { key: 'ai', title: 'AI employees', primary: 'ai' },
50
+ { key: 'system', title: 'Other settings' },
51
+ ];
52
+
53
+ export const DEFAULT_SETTINGS_GROUP: SettingsGroupKey = 'system';
54
+
55
+ /**
56
+ * 内置配置项到分组的映射。
57
+ *
58
+ * key 为 `pluginSettingsManager.addMenuItem` 时传入的 `key`;
59
+ * 没列出来的一律落到其他设置,第三方插件不改代码也能出现。
60
+ */
61
+ const BUILTIN_SETTINGS_GROUP_MAP: Record<string, SettingsGroupKey> = {
62
+ 'multi-portal': 'applications',
63
+ 'plugin-manager': 'plugins',
64
+ 'data-source-manager': 'data',
65
+ 'users-permissions': 'access',
66
+ workflow: 'automation',
67
+ ai: 'ai',
68
+ };
69
+
70
+ /**
71
+ * 解析某个顶级配置项所属的分组。
72
+ *
73
+ * @param {PluginSettingsPageType | null | undefined} setting 顶级配置项
74
+ * @returns {SettingsGroupKey} 分组 key
75
+ */
76
+ export function getSettingsGroupKey(setting?: PluginSettingsPageType | null): SettingsGroupKey {
77
+ if (!setting) {
78
+ return DEFAULT_SETTINGS_GROUP;
79
+ }
80
+
81
+ const declared = (setting as PluginSettingsPageType & { group?: string }).group;
82
+ if (declared && SETTINGS_GROUPS.some((group) => group.key === declared)) {
83
+ return declared as SettingsGroupKey;
84
+ }
85
+
86
+ return BUILTIN_SETTINGS_GROUP_MAP[setting.name] || DEFAULT_SETTINGS_GROUP;
87
+ }
88
+
89
+ /**
90
+ * 按分组切分顶级配置项,并丢掉没有任何配置项的空分组。
91
+ *
92
+ * @param {PluginSettingsPageType[]} settings 已排序的顶级配置项
93
+ * @returns 非空分组
94
+ */
95
+ export function groupTopLevelSettings(settings: readonly PluginSettingsPageType[] = []) {
96
+ return SETTINGS_GROUPS.map((group) => {
97
+ const members = settings.filter((setting) => getSettingsGroupKey(setting) === group.key);
98
+ const primaryIndex = group.primary ? members.findIndex((item) => item.name === group.primary) : -1;
99
+
100
+ return {
101
+ ...group,
102
+ settings:
103
+ primaryIndex > 0
104
+ ? [members[primaryIndex], ...members.slice(0, primaryIndex), ...members.slice(primaryIndex + 1)]
105
+ : members,
106
+ };
107
+ }).filter((group) => group.settings.length > 0);
108
+ }