@nocobase/client-v2 2.3.0-beta.6 → 2.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +11 -2
  4. package/es/components/AttachmentUpload.d.ts +51 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/index.d.ts +4 -1
  8. package/es/index.mjs +129 -118
  9. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  10. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  11. package/es/settings-app/SettingsApplication.d.ts +18 -0
  12. package/es/settings-app/SettingsBrand.d.ts +16 -0
  13. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  14. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  15. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  16. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  17. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  18. package/es/settings-app/SettingsSearch.d.ts +17 -0
  19. package/es/settings-app/SettingsShell.d.ts +10 -0
  20. package/es/settings-app/isSettingsApp.d.ts +18 -0
  21. package/es/settings-app/runtimePaths.d.ts +18 -0
  22. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  23. package/es/settings-app/settingsTheme.d.ts +87 -0
  24. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  25. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  26. package/es/settings-center/groups.d.ts +66 -0
  27. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  28. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  29. package/es/settings-center/utils.d.ts +24 -0
  30. package/es/theme/index.d.ts +1 -0
  31. package/es/theme/itemActive.d.ts +24 -0
  32. package/es/theme/type.d.ts +9 -0
  33. package/lib/index.js +157 -146
  34. package/package.json +7 -7
  35. package/src/__tests__/app.test.tsx +122 -3
  36. package/src/__tests__/authRedirect.test.ts +154 -0
  37. package/src/__tests__/browserChecker.test.ts +172 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +168 -130
  44. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  47. package/src/__tests__/settings-shell.test.tsx +261 -0
  48. package/src/acl/ACLProvider.tsx +53 -3
  49. package/src/acl/aclCheckReadiness.ts +44 -0
  50. package/src/authRedirect.ts +85 -8
  51. package/src/components/AppComponents.tsx +16 -7
  52. package/src/components/AttachmentUpload.tsx +275 -0
  53. package/src/components/index.ts +1 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  55. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  56. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  57. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  58. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  59. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  60. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  61. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  62. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  63. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  64. package/src/index.ts +11 -1
  65. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  66. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  67. package/src/settings-app/SettingsApplication.ts +31 -0
  68. package/src/settings-app/SettingsBrand.tsx +108 -0
  69. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  70. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  71. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  72. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  73. package/src/settings-app/SettingsRouterManager.ts +92 -0
  74. package/src/settings-app/SettingsSearch.tsx +343 -0
  75. package/src/settings-app/SettingsShell.tsx +184 -0
  76. package/src/settings-app/isSettingsApp.ts +21 -0
  77. package/src/settings-app/runtimePaths.ts +103 -0
  78. package/src/settings-app/settingsDocumentPath.ts +66 -0
  79. package/src/settings-app/settingsTheme.ts +369 -0
  80. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  81. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  82. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  83. package/src/settings-center/__tests__/groups.test.ts +25 -0
  84. package/src/settings-center/groups.ts +108 -0
  85. package/src/settings-center/useSettingsGroups.ts +105 -0
  86. package/src/settings-center/useSettingsSearch.ts +173 -0
  87. package/src/settings-center/utils.tsx +50 -1
  88. package/src/theme/index.tsx +1 -0
  89. package/src/theme/itemActive.ts +31 -0
  90. package/src/theme/type.ts +10 -0
@@ -7,61 +7,92 @@
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, Result, Tabs, theme } from 'antd';
15
14
  import React, { useEffect, useMemo, useRef } from 'react';
16
15
  import { useTranslation } from 'react-i18next';
17
16
  import { generatePath, Navigate, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
18
- import { useACLRoleContext } from '../acl';
19
- import { ADMIN_SETTINGS_PATH, type PluginSettingsPageType } from '../PluginSettingsManager';
17
+ import { getModernClientPrefix } from '../authRedirect';
18
+ import type { PluginSettingsPageType } from '../PluginSettingsManager';
20
19
  import { useApp } from '../hooks/useApp';
20
+ import { resolveSettingsAppScopeWithinPublicPath } from '../settings-app/settingsDocumentPath';
21
21
  import { AdminSettingsLayoutModel } from './AdminSettingsLayoutModel';
22
+ import { useSettingsGroups } from './useSettingsGroups';
22
23
  import {
23
24
  ADMIN_SETTINGS_LAYOUT_MODEL_UID,
24
25
  createSettingsPathMap,
25
- filterRenderableSettings,
26
- filterVisibleSettings,
27
26
  getDefaultSettingsPath,
28
- getMenuItems,
29
27
  matchSettingsRoute,
30
- PLUGIN_MANAGER_SETTING_NAME,
31
- replaceRouteParams,
32
- sortTopLevelSettings,
33
28
  } from './utils';
34
29
 
35
- function SettingsEmpty(props: { type: 'home' | 'route' }) {
30
+ /**
31
+ * Maximum width of the content area.
32
+ *
33
+ * Centers and narrows the content on wide screens: settings pages are mostly forms
34
+ * and lists, and across 2560px a single line sweeps the whole display, which is
35
+ * tiring to read and leaves the actions on the right far from the title on the left.
36
+ */
37
+ const SETTINGS_CONTENT_MAX_WIDTH = 1280;
38
+
39
+ function AdminDocumentRedirect() {
40
+ const app = useApp();
41
+ const rootPublicPath = app.getPublicPath().replace(/\/+$/, '');
42
+ const appScope = resolveSettingsAppScopeWithinPublicPath(app.getPublicPath(), app.router.getBasename?.());
43
+ const targetPath = `${rootPublicPath}/${getModernClientPrefix()}${appScope}/admin`;
44
+
45
+ useEffect(() => {
46
+ window.location.replace(targetPath);
47
+ }, [targetPath]);
48
+
49
+ return app.renderComponent('AppSpin');
50
+ }
51
+
52
+ function SettingsEmpty(props: { type: 'forbidden' | 'home' | 'not-found' }) {
36
53
  const { type } = props;
37
54
  const { t } = useTranslation();
38
55
 
39
- if (type === 'route') {
40
- return (
41
- <Result
42
- status="warning"
43
- title={t('Current settings page is unavailable')}
44
- subTitle={t('The requested settings page does not exist or you do not have permission to access it.')}
45
- />
56
+ let status: '403' | '404' | 'info';
57
+ let title: string;
58
+ let subTitle: string;
59
+
60
+ if (type === 'forbidden') {
61
+ status = '403';
62
+ title = t('Your current role cannot access Settings');
63
+ subTitle = t('Switch to a role with access, or contact an administrator to request access.');
64
+ } else if (type === 'not-found') {
65
+ status = '404';
66
+ title = t('Settings page not found');
67
+ subTitle = t('The settings page you requested does not exist or has been removed.');
68
+ } else {
69
+ status = 'info';
70
+ title = t('No settings pages available');
71
+ subTitle = t(
72
+ 'No settings pages are currently available in Client V2. Settings registered by migrated plugins will appear here automatically.',
46
73
  );
47
74
  }
48
75
 
49
76
  return (
50
- <Result
51
- status="info"
52
- title={t('No settings pages available')}
53
- subTitle={t(
54
- 'No settings pages are currently available in Client V2. Settings registered by migrated plugins will appear here automatically.',
55
- )}
56
- />
77
+ <div role="status" aria-atomic="true">
78
+ <Result
79
+ status={status}
80
+ title={
81
+ <span role="heading" aria-level={1}>
82
+ {title}
83
+ </span>
84
+ }
85
+ subTitle={subTitle}
86
+ />
87
+ </div>
57
88
  );
58
89
  }
59
90
 
60
91
  /**
61
92
  * `client-v2` 的 settings 页面壳实现。
62
93
  *
63
- * 该组件负责左侧菜单、顶部 tabs、默认落点和两类空态,
64
- * 页面内容本身继续由各个 settings route 的 `<Outlet />` 渲染。
94
+ * 一级分组铺在顶栏(见 `SettingsGroupNav`),这里负责当前分组下的二三级嵌套菜单、
95
+ * 默认落点和两类空态,页面内容本身继续由各个 settings route 的 `<Outlet />` 渲染。
65
96
  */
66
97
  export const InternalAdminSettingsLayout = () => {
67
98
  const app = useApp();
@@ -72,48 +103,18 @@ export const InternalAdminSettingsLayout = () => {
72
103
  Object.entries(params).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
73
104
  );
74
105
  const { token } = theme.useToken();
75
- const { t } = useTranslation();
76
- const { snippets = [] } = useACLRoleContext();
106
+ const {
107
+ allSettings,
108
+ currentSetting,
109
+ currentTopLevelSetting,
110
+ visibleSettings: allVisibleSettings,
111
+ } = useSettingsGroups();
77
112
 
78
- const allSettings = useMemo(
79
- () => filterRenderableSettings(app.pluginSettingsManager.getList(false) as PluginSettingsPageType[]),
80
- [app.pluginSettingsManager],
81
- );
82
- const visibleSettings = useMemo(
83
- () =>
84
- filterVisibleSettings(
85
- filterRenderableSettings(app.pluginSettingsManager.getList(true) as PluginSettingsPageType[]),
86
- ),
87
- [app.pluginSettingsManager],
88
- );
89
- const pluginManagerSetting = useMemo(
90
- () => visibleSettings.find((item) => item.name === PLUGIN_MANAGER_SETTING_NAME) || null,
91
- [visibleSettings],
92
- );
93
- const normalSettings = useMemo(
94
- () => sortTopLevelSettings(visibleSettings.filter((item) => item.name !== PLUGIN_MANAGER_SETTING_NAME)),
95
- [visibleSettings],
96
- );
97
- const allVisibleSettings = useMemo(
98
- () => (pluginManagerSetting ? [pluginManagerSetting, ...normalSettings] : normalSettings),
99
- [normalSettings, pluginManagerSetting],
100
- );
101
- const registeredSettingsMapByPath = useMemo(() => createSettingsPathMap(allSettings), [allSettings]);
102
113
  const visibleSettingsMapByPath = useMemo(() => createSettingsPathMap(allVisibleSettings), [allVisibleSettings]);
103
- const currentSetting = useMemo(
104
- () => matchSettingsRoute(registeredSettingsMapByPath, location.pathname),
105
- [location.pathname, registeredSettingsMapByPath],
106
- );
107
114
  const currentVisibleSetting = useMemo(
108
115
  () => matchSettingsRoute(visibleSettingsMapByPath, location.pathname),
109
116
  [location.pathname, visibleSettingsMapByPath],
110
117
  );
111
- const currentTopLevelSetting = useMemo(() => {
112
- if (!currentSetting) {
113
- return null;
114
- }
115
- return allSettings.find((item) => item.name === currentSetting.topLevelName) || currentSetting;
116
- }, [allSettings, currentSetting]);
117
118
  const currentVisibleTopLevelSetting = useMemo(() => {
118
119
  if (!currentSetting) {
119
120
  return null;
@@ -121,10 +122,34 @@ export const InternalAdminSettingsLayout = () => {
121
122
  return allVisibleSettings.find((item) => item.name === currentSetting.topLevelName) || null;
122
123
  }, [allVisibleSettings, currentSetting]);
123
124
  const defaultSettingsPath = useMemo(() => getDefaultSettingsPath(allVisibleSettings), [allVisibleSettings]);
124
- const currentVisibleTabs = useMemo(() => {
125
- return (currentVisibleTopLevelSetting?.children || []).filter((item) => !item.hidden) as PluginSettingsPageType[];
126
- }, [currentVisibleTopLevelSetting?.children]);
127
- const shouldShowTabs = currentVisibleTabs.length > 1 && currentVisibleTopLevelSetting?.showTabs !== false;
125
+ const settingsRootPath = app.pluginSettingsManager.getRoutePath('');
126
+ const settingsRootPathWithoutTrailingSlash = settingsRootPath.replace(/\/$/, '');
127
+
128
+ // No sidebar: single-entry groups never needed one, and multi-entry groups such as
129
+ // "other settings" now use the hover dropdown in the top bar. Children of a
130
+ // top-level setting (users / roles / sync) keep using the tabs under the header.
131
+ // 子页面一律走页头下的 Tab:左栏只表达「哪个模块」,模块内部的分页交给 Tab,
132
+ // 和 v1 设置中心保持一致。
133
+ const pageTabs = useMemo(() => {
134
+ const children = (currentVisibleTopLevelSetting?.children || []).filter(
135
+ (item) => !item.hidden && !item.link && item.path,
136
+ );
137
+ return children.length > 1 ? children.map((item) => ({ key: item.path, label: item.label ?? item.title })) : [];
138
+ }, [currentVisibleTopLevelSetting]);
139
+ const activeTabKey = useMemo(() => {
140
+ if (!pageTabs.length) {
141
+ return undefined;
142
+ }
143
+ const matched = pageTabs.find(
144
+ (tab) => location.pathname === tab.key || location.pathname.startsWith(`${tab.key}/`),
145
+ );
146
+ return matched?.key ?? pageTabs[0]?.key;
147
+ }, [location.pathname, pageTabs]);
148
+ // 页头只需要补一句「当前在哪个子页」。
149
+ const pageSubTitle =
150
+ currentVisibleSetting && currentVisibleSetting.title !== currentTopLevelSetting?.title
151
+ ? currentVisibleSetting.title
152
+ : undefined;
128
153
 
129
154
  useEffect(() => {
130
155
  const nextTitle =
@@ -137,33 +162,22 @@ export const InternalAdminSettingsLayout = () => {
137
162
  }
138
163
  }, [currentTopLevelSetting]);
139
164
 
140
- const sidebarMenus = useMemo(() => {
141
- const items: any[] = [];
142
-
143
- if (pluginManagerSetting && snippets.includes('pm')) {
144
- items.push({
145
- key: pluginManagerSetting.name,
146
- icon: pluginManagerSetting.icon || <ApiOutlined />,
147
- label: pluginManagerSetting.label || t('Plugin manager'),
148
- });
149
- }
150
-
151
- if (items.length && normalSettings.length) {
152
- items.push({ type: 'divider' });
153
- }
154
-
155
- const normalMenuItems =
156
- getMenuItems(normalSettings.map((item) => ({ ...item, children: undefined }) as PluginSettingsPageType)) || [];
157
-
158
- items.push(...normalMenuItems);
159
-
160
- return items;
161
- }, [normalSettings, pluginManagerSetting, snippets, t]);
162
-
163
165
  const shouldRedirectToDefault =
164
- location.pathname === ADMIN_SETTINGS_PATH ||
165
- location.pathname === ADMIN_SETTINGS_PATH.replace(/\/$/, '') ||
166
- location.pathname === `${ADMIN_SETTINGS_PATH}index`;
166
+ location.pathname === settingsRootPath ||
167
+ location.pathname === settingsRootPathWithoutTrailingSlash ||
168
+ location.pathname === `${settingsRootPath}index`;
169
+ const currentPathWithoutTrailingSlash = location.pathname.replace(/\/+$/, '');
170
+ const currentTopLevelPathWithoutTrailingSlash = currentTopLevelSetting?.path.replace(/\/+$/, '');
171
+ const isTopLevelSettingsPath =
172
+ currentPathWithoutTrailingSlash === currentTopLevelPathWithoutTrailingSlash &&
173
+ !!currentTopLevelSetting?.children?.length;
174
+ const visibleIndexPath = isTopLevelSettingsPath
175
+ ? currentVisibleTopLevelSetting?.children?.find((item) => item.pageKey === 'index')?.path
176
+ : undefined;
177
+ const firstVisibleChildPath = isTopLevelSettingsPath
178
+ ? getDefaultSettingsPath(currentVisibleTopLevelSetting?.children as PluginSettingsPageType[])
179
+ : undefined;
180
+ const nextVisibleChildPath = visibleIndexPath || firstVisibleChildPath;
167
181
 
168
182
  if (shouldRedirectToDefault && defaultSettingsPath) {
169
183
  return <Navigate replace to={defaultSettingsPath} />;
@@ -174,11 +188,11 @@ export const InternalAdminSettingsLayout = () => {
174
188
  }
175
189
 
176
190
  if (!currentSetting) {
177
- return <SettingsEmpty type="route" />;
191
+ return <SettingsEmpty type="not-found" />;
178
192
  }
179
193
 
180
194
  if (currentSetting.isAllow === false) {
181
- const firstVisibleTabPath = currentVisibleTabs
195
+ const firstVisibleTabPath = (currentVisibleTopLevelSetting?.children || [])
182
196
  .map((tab) => getDefaultSettingsPath([tab]))
183
197
  .filter((path): path is string => typeof path === 'string')
184
198
  .map((path) => {
@@ -189,30 +203,24 @@ export const InternalAdminSettingsLayout = () => {
189
203
  }
190
204
  })
191
205
  .find((path): path is string => typeof path === 'string');
206
+
192
207
  if (firstVisibleTabPath) {
193
208
  return <Navigate replace to={firstVisibleTabPath} />;
194
209
  }
210
+
195
211
  if (!defaultSettingsPath) {
196
- return <Navigate replace to="/admin" />;
212
+ return <AdminDocumentRedirect />;
197
213
  }
198
214
 
199
- return <SettingsEmpty type="route" />;
215
+ return <SettingsEmpty type="forbidden" />;
200
216
  }
201
217
 
202
218
  if (currentSetting.link) {
203
219
  return <Navigate replace to={currentSetting.link} />;
204
220
  }
205
221
 
206
- if (location.pathname === currentTopLevelSetting?.path && currentTopLevelSetting?.children?.length) {
207
- const visibleIndexPath = currentVisibleTopLevelSetting?.children?.find((item) => item.pageKey === 'index')?.path;
208
- const firstVisibleChildPath = getDefaultSettingsPath(
209
- currentVisibleTopLevelSetting?.children as PluginSettingsPageType[],
210
- );
211
- const nextPath = visibleIndexPath || firstVisibleChildPath;
212
-
213
- if (nextPath && nextPath !== location.pathname) {
214
- return <Navigate replace to={nextPath} />;
215
- }
222
+ if (nextVisibleChildPath && nextVisibleChildPath !== location.pathname) {
223
+ return <Navigate replace to={nextVisibleChildPath} />;
216
224
  }
217
225
 
218
226
  return (
@@ -225,41 +233,6 @@ export const InternalAdminSettingsLayout = () => {
225
233
  overflow: 'hidden',
226
234
  }}
227
235
  >
228
- <Layout.Sider
229
- width={200}
230
- style={{
231
- background: token.colorBgContainer,
232
- borderInlineEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
233
- minHeight: 0,
234
- overflowY: 'auto',
235
- overflowX: 'hidden',
236
- }}
237
- >
238
- <Menu
239
- selectedKeys={currentVisibleTopLevelSetting?.name ? [currentVisibleTopLevelSetting.name] : []}
240
- style={{ height: '100%', borderInlineEnd: 'none' }}
241
- onClick={({ key }) => {
242
- const topLevelSetting = allVisibleSettings.find((item) => item.name === key);
243
- if (!topLevelSetting) {
244
- return;
245
- }
246
-
247
- if (topLevelSetting.link) {
248
- window.open(topLevelSetting.link, '_blank', 'noopener,noreferrer');
249
- return;
250
- }
251
-
252
- const firstPath = topLevelSetting.children?.length
253
- ? getDefaultSettingsPath(topLevelSetting.children)
254
- : topLevelSetting.path;
255
-
256
- if (firstPath) {
257
- navigate(firstPath);
258
- }
259
- }}
260
- items={sidebarMenus}
261
- />
262
- </Layout.Sider>
263
236
  <Layout.Content
264
237
  style={{
265
238
  background: token.colorBgLayout,
@@ -271,41 +244,53 @@ export const InternalAdminSettingsLayout = () => {
271
244
  overflow: 'hidden',
272
245
  }}
273
246
  >
274
- <PageHeader
275
- ghost={false}
276
- title={currentTopLevelSetting?.title}
277
- style={{
278
- background: token.colorBgContainer,
279
- borderBlockEnd: `${token.lineWidth}px solid ${token.colorBorderSecondary}`,
280
- paddingBottom: shouldShowTabs ? 0 : token.padding,
281
- }}
282
- footer={
283
- shouldShowTabs ? (
284
- <Menu
285
- mode="horizontal"
286
- selectedKeys={[currentVisibleSetting?.name || currentSetting?.name]}
287
- items={getMenuItems(currentVisibleTabs)}
288
- onClick={({ key }) => {
289
- const targetPath = replaceRouteParams(app.pluginSettingsManager.getRoutePath(String(key)), params);
290
- if (location.pathname !== targetPath) {
291
- navigate(targetPath);
292
- }
293
- }}
294
- style={{ marginLeft: -token.marginSM, borderBottom: 'none' }}
295
- />
296
- ) : null
297
- }
298
- />
247
+ {/*
248
+ 页头放在滚动容器**内部**:钉在外面会让页面出现嵌套滚动(窗口一条、内容区一条),
249
+ 插件页面自己再带滚动区时会套成两三层,而且长页面白白少掉一条页头的高度。
250
+ */}
299
251
  <div
300
252
  style={{
301
253
  flex: 1,
302
254
  minHeight: 0,
303
255
  boxSizing: 'border-box',
304
256
  overflow: 'auto',
305
- padding: token.paddingLG,
306
257
  }}
307
258
  >
308
- <Outlet />
259
+ {/*
260
+ The header is no longer a full-width white band: title and content share one
261
+ background and one max width, so the page reads as a single surface rather
262
+ than a title bar stacked on a content area.
263
+ */}
264
+ <div style={{ margin: '0 auto', maxWidth: SETTINGS_CONTENT_MAX_WIDTH, padding: token.paddingLG }}>
265
+ <PageHeader
266
+ ghost
267
+ title={currentTopLevelSetting?.title}
268
+ subTitle={pageTabs.length ? undefined : pageSubTitle}
269
+ footer={
270
+ pageTabs.length ? (
271
+ <Tabs
272
+ activeKey={activeTabKey}
273
+ items={pageTabs}
274
+ tabBarStyle={{ marginBottom: 0 }}
275
+ onChange={(key) => {
276
+ if (key !== location.pathname) {
277
+ navigate(key);
278
+ }
279
+ }}
280
+ />
281
+ ) : undefined
282
+ }
283
+ // With the white band gone there is nothing separating header from content,
284
+ // so spacing does the job the bottom border used to do implicitly.
285
+ style={{
286
+ background: 'transparent',
287
+ marginBottom: token.marginLG,
288
+ paddingBlock: 0,
289
+ paddingInline: 0,
290
+ }}
291
+ />
292
+ <Outlet />
293
+ </div>
309
294
  </div>
310
295
  </Layout.Content>
311
296
  </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,25 @@
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 { describe, expect, it } from 'vitest';
11
+ import { SETTINGS_GROUPS } from '../groups';
12
+
13
+ describe('settings center groups', () => {
14
+ it('places the plugin manager immediately after AI employees', () => {
15
+ expect(SETTINGS_GROUPS.map((group) => group.key)).toEqual([
16
+ 'applications',
17
+ 'data',
18
+ 'access',
19
+ 'automation',
20
+ 'ai',
21
+ 'plugins',
22
+ 'system',
23
+ ]);
24
+ });
25
+ });