@nocobase/client-v2 2.3.0-beta.6 → 2.4.0-alpha.2
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.
- package/es/acl/ACLProvider.d.ts +10 -0
- package/es/acl/aclCheckReadiness.d.ts +11 -0
- package/es/authRedirect.d.ts +11 -2
- package/es/components/AttachmentUpload.d.ts +51 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
- package/es/index.d.ts +4 -1
- package/es/index.mjs +129 -118
- package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
- package/es/nocobase-buildin-plugin/index.d.ts +10 -0
- package/es/settings-app/SettingsApplication.d.ts +18 -0
- package/es/settings-app/SettingsBrand.d.ts +16 -0
- package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
- package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
- package/es/settings-app/SettingsGroupNav.d.ts +18 -0
- package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
- package/es/settings-app/SettingsRouterManager.d.ts +15 -0
- package/es/settings-app/SettingsSearch.d.ts +17 -0
- package/es/settings-app/SettingsShell.d.ts +10 -0
- package/es/settings-app/isSettingsApp.d.ts +18 -0
- package/es/settings-app/runtimePaths.d.ts +18 -0
- package/es/settings-app/settingsDocumentPath.d.ts +14 -0
- package/es/settings-app/settingsTheme.d.ts +87 -0
- package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
- package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
- package/es/settings-center/groups.d.ts +66 -0
- package/es/settings-center/useSettingsGroups.d.ts +36 -0
- package/es/settings-center/useSettingsSearch.d.ts +35 -0
- package/es/settings-center/utils.d.ts +24 -0
- package/es/theme/index.d.ts +1 -0
- package/es/theme/itemActive.d.ts +24 -0
- package/es/theme/type.d.ts +9 -0
- package/lib/index.js +157 -146
- package/package.json +7 -7
- package/src/__tests__/app.test.tsx +122 -3
- package/src/__tests__/authRedirect.test.ts +154 -0
- package/src/__tests__/browserChecker.test.ts +172 -6
- package/src/__tests__/mockSettingsApplication.ts +29 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
- package/src/__tests__/plugin-manager.test.tsx +7 -6
- package/src/__tests__/settings-application.test.ts +164 -0
- package/src/__tests__/settings-auth-routes.test.ts +71 -0
- package/src/__tests__/settings-center.test.tsx +168 -130
- package/src/__tests__/settings-layout-root.test.tsx +600 -0
- package/src/__tests__/settings-runtime-paths.test.ts +99 -0
- package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
- package/src/__tests__/settings-shell.test.tsx +261 -0
- package/src/acl/ACLProvider.tsx +53 -3
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/authRedirect.ts +85 -8
- package/src/components/AppComponents.tsx +16 -7
- package/src/components/AttachmentUpload.tsx +275 -0
- package/src/components/index.ts +1 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
- package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
- package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
- package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
- package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
- package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
- package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
- package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
- package/src/index.ts +11 -1
- package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
- package/src/nocobase-buildin-plugin/index.tsx +112 -71
- package/src/settings-app/SettingsApplication.ts +31 -0
- package/src/settings-app/SettingsBrand.tsx +108 -0
- package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
- package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
- package/src/settings-app/SettingsGroupNav.tsx +124 -0
- package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
- package/src/settings-app/SettingsRouterManager.ts +92 -0
- package/src/settings-app/SettingsSearch.tsx +343 -0
- package/src/settings-app/SettingsShell.tsx +184 -0
- package/src/settings-app/isSettingsApp.ts +21 -0
- package/src/settings-app/runtimePaths.ts +103 -0
- package/src/settings-app/settingsDocumentPath.ts +66 -0
- package/src/settings-app/settingsTheme.ts +369 -0
- package/src/settings-app/useSettingsThemeConfig.ts +100 -0
- package/src/settings-center/AdminSettingsLayout.tsx +154 -169
- package/src/settings-center/SystemSettingsPage.tsx +4 -170
- package/src/settings-center/__tests__/groups.test.ts +25 -0
- package/src/settings-center/groups.ts +108 -0
- package/src/settings-center/useSettingsGroups.ts +105 -0
- package/src/settings-center/useSettingsSearch.ts +173 -0
- package/src/settings-center/utils.tsx +50 -1
- package/src/theme/index.tsx +1 -0
- package/src/theme/itemActive.ts +31 -0
- 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,
|
|
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 {
|
|
19
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
64
|
-
*
|
|
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 {
|
|
76
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 ===
|
|
165
|
-
location.pathname ===
|
|
166
|
-
location.pathname === `${
|
|
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="
|
|
191
|
+
return <SettingsEmpty type="not-found" />;
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
if (currentSetting.isAllow === false) {
|
|
181
|
-
const firstVisibleTabPath =
|
|
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 <
|
|
212
|
+
return <AdminDocumentRedirect />;
|
|
197
213
|
}
|
|
198
214
|
|
|
199
|
-
return <SettingsEmpty type="
|
|
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 (
|
|
207
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
<
|
|
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
|
+
});
|