@nocobase/client-v2 2.3.0-alpha.1 → 3.0.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.
Files changed (123) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/actions/linkageRules.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/FlowRoute.d.ts +2 -2
  9. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  10. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  11. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  12. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  14. package/es/index.d.ts +4 -1
  15. package/es/index.mjs +139 -125
  16. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  17. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  18. package/es/settings-app/SettingsApplication.d.ts +18 -0
  19. package/es/settings-app/SettingsBrand.d.ts +16 -0
  20. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  21. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  22. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  23. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  24. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  25. package/es/settings-app/SettingsSearch.d.ts +17 -0
  26. package/es/settings-app/SettingsShell.d.ts +10 -0
  27. package/es/settings-app/isSettingsApp.d.ts +18 -0
  28. package/es/settings-app/runtimePaths.d.ts +18 -0
  29. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  30. package/es/settings-app/settingsTheme.d.ts +87 -0
  31. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  32. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  33. package/es/settings-center/groups.d.ts +66 -0
  34. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  35. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  36. package/es/settings-center/utils.d.ts +24 -0
  37. package/es/theme/index.d.ts +1 -0
  38. package/es/theme/itemActive.d.ts +24 -0
  39. package/es/theme/type.d.ts +9 -0
  40. package/lib/index.js +141 -127
  41. package/lib/locale/languageCodes.js +1 -0
  42. package/package.json +8 -7
  43. package/src/__tests__/authRedirect.test.ts +137 -0
  44. package/src/__tests__/browserChecker.test.ts +124 -6
  45. package/src/__tests__/mockSettingsApplication.ts +29 -0
  46. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +51 -10
  47. package/src/__tests__/plugin-manager.test.tsx +7 -6
  48. package/src/__tests__/settings-application.test.ts +164 -0
  49. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  50. package/src/__tests__/settings-center.test.tsx +38 -37
  51. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  52. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  53. package/src/__tests__/settings-shell.test.tsx +200 -0
  54. package/src/acl/ACLProvider.tsx +21 -0
  55. package/src/authRedirect.ts +72 -3
  56. package/src/components/AttachmentUpload.tsx +275 -0
  57. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  58. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  59. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  60. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  63. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  64. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  65. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  66. package/src/flow/actions/linkageRules.tsx +26 -6
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  68. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  69. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FlowRoute.tsx +71 -29
  74. package/src/flow/index.ts +2 -0
  75. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  76. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  77. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  78. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  79. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  80. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  81. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  82. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  83. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  85. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  86. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  87. package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
  88. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  89. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  90. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  93. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  94. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  95. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  96. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  97. package/src/index.ts +10 -1
  98. package/src/locale/languageCodes.ts +1 -0
  99. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  100. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  101. package/src/settings-app/SettingsApplication.ts +31 -0
  102. package/src/settings-app/SettingsBrand.tsx +108 -0
  103. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  104. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  105. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  106. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  107. package/src/settings-app/SettingsRouterManager.ts +92 -0
  108. package/src/settings-app/SettingsSearch.tsx +238 -0
  109. package/src/settings-app/SettingsShell.tsx +167 -0
  110. package/src/settings-app/isSettingsApp.ts +21 -0
  111. package/src/settings-app/runtimePaths.ts +104 -0
  112. package/src/settings-app/settingsDocumentPath.ts +66 -0
  113. package/src/settings-app/settingsTheme.ts +301 -0
  114. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  115. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  116. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  117. package/src/settings-center/groups.ts +108 -0
  118. package/src/settings-center/useSettingsGroups.ts +105 -0
  119. package/src/settings-center/useSettingsSearch.ts +173 -0
  120. package/src/settings-center/utils.tsx +50 -1
  121. package/src/theme/index.tsx +1 -0
  122. package/src/theme/itemActive.ts +31 -0
  123. package/src/theme/type.ts +10 -0
@@ -0,0 +1,100 @@
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 { theme as antdTheme, type ThemeConfig } from 'antd';
11
+ import { useEffect, useState } from 'react';
12
+ import { useApp } from '../hooks/useApp';
13
+
14
+ /** 简约主题在 `themeConfig` 表里的固定标识,见 plugin-theme-editor 的 builtinThemes */
15
+ export const MINIMAL_THEME_UID = 'minimal';
16
+
17
+ const ALGORITHMS = {
18
+ defaultAlgorithm: antdTheme.defaultAlgorithm,
19
+ darkAlgorithm: antdTheme.darkAlgorithm,
20
+ compactAlgorithm: antdTheme.compactAlgorithm,
21
+ };
22
+
23
+ /**
24
+ * 把库里存的 algorithm 名字换成 antd 的算法函数。
25
+ *
26
+ * 主题记录里存的是字符串(JSON 存不下函数),可能是单个也可能是数组。
27
+ *
28
+ * @param {unknown} value 库里存的 algorithm 字段
29
+ * @returns {ThemeConfig['algorithm'] | undefined} antd 算法
30
+ */
31
+ function toAlgorithm(value: unknown): ThemeConfig['algorithm'] | undefined {
32
+ const names = (Array.isArray(value) ? value : [value]).filter((name): name is keyof typeof ALGORITHMS =>
33
+ Boolean(name && typeof name === 'string' && ALGORITHMS[name]),
34
+ );
35
+
36
+ if (!names.length) {
37
+ return undefined;
38
+ }
39
+
40
+ return names.length === 1 ? ALGORITHMS[names[0]] : names.map((name) => ALGORITHMS[name]);
41
+ }
42
+
43
+ /**
44
+ * 读取设置中心主题。
45
+ *
46
+ * 设置中心的外观由 `themeConfig` 里那条「简约」记录约束,在主题编辑器里就能改;
47
+ * 它本身是一条普通的可选主题,别的应用要同一套观感直接选它即可。
48
+ * 读不到(主题编辑器插件没启、老实例还没迁移、请求失败)时返回 `null`,
49
+ * 由调用方回落到代码里的默认中性配色,避免设置中心突然变回一片蓝。
50
+ *
51
+ * @returns {ThemeConfig | null | undefined} 主题配置;`undefined` 表示还没读完
52
+ */
53
+ export function useSettingsThemeConfig(): ThemeConfig | null | undefined {
54
+ const app = useApp();
55
+ const [themeConfig, setThemeConfig] = useState<ThemeConfig | null | undefined>(undefined);
56
+
57
+ useEffect(() => {
58
+ let cancelled = false;
59
+
60
+ const load = async () => {
61
+ try {
62
+ const response = await app.apiClient.request({
63
+ resource: 'themeConfig',
64
+ action: 'list',
65
+ params: { paginate: false },
66
+ });
67
+ const items: any[] = response?.data?.data || [];
68
+ const matched = items.find((item) => item?.uid === MINIMAL_THEME_UID);
69
+ const config = matched?.config;
70
+
71
+ if (cancelled) {
72
+ return;
73
+ }
74
+
75
+ if (!config) {
76
+ setThemeConfig(null);
77
+ return;
78
+ }
79
+
80
+ setThemeConfig({
81
+ token: config.token,
82
+ components: config.components,
83
+ algorithm: toAlgorithm(config.algorithm),
84
+ });
85
+ } catch (error) {
86
+ if (!cancelled) {
87
+ setThemeConfig(null);
88
+ }
89
+ }
90
+ };
91
+
92
+ void load();
93
+
94
+ return () => {
95
+ cancelled = true;
96
+ };
97
+ }, [app.apiClient]);
98
+
99
+ return themeConfig;
100
+ }
@@ -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} />