@nocobase/client-v2 2.3.0-beta.3 → 3.0.0-alpha.11

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 (85) 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 +128 -117
  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/models/topbar/TopbarActionModel.tsx +52 -180
  59. package/src/index.ts +11 -1
  60. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  61. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  62. package/src/settings-app/SettingsApplication.ts +31 -0
  63. package/src/settings-app/SettingsBrand.tsx +108 -0
  64. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  65. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  66. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  67. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  68. package/src/settings-app/SettingsRouterManager.ts +92 -0
  69. package/src/settings-app/SettingsSearch.tsx +343 -0
  70. package/src/settings-app/SettingsShell.tsx +184 -0
  71. package/src/settings-app/isSettingsApp.ts +21 -0
  72. package/src/settings-app/runtimePaths.ts +103 -0
  73. package/src/settings-app/settingsDocumentPath.ts +66 -0
  74. package/src/settings-app/settingsTheme.ts +369 -0
  75. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  76. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  77. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  78. package/src/settings-center/__tests__/groups.test.ts +25 -0
  79. package/src/settings-center/groups.ts +108 -0
  80. package/src/settings-center/useSettingsGroups.ts +105 -0
  81. package/src/settings-center/useSettingsSearch.ts +173 -0
  82. package/src/settings-center/utils.tsx +50 -1
  83. package/src/theme/index.tsx +1 -0
  84. package/src/theme/itemActive.ts +31 -0
  85. package/src/theme/type.ts +10 -0
@@ -13,6 +13,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, t
13
13
  import { useLocation } from 'react-router-dom';
14
14
  import { writeFlowSettingsPreference } from '../flow/admin-shell/admin-layout/flowSettingsPreference';
15
15
  import { useApp } from '../hooks/useApp';
16
+ import { getACLCheckReady, setACLCheckReady } from './aclCheckReadiness';
16
17
  import { createAclSnippetAllow } from './createAclSnippetAllow';
17
18
 
18
19
  export type ACLRoleData = {
@@ -85,16 +86,23 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
85
86
  const location = useLocation();
86
87
  const aclStore = ensureACLStore(app);
87
88
  const [loading, setLoading] = useState(false);
89
+ const refreshGenerationRef = useRef(0);
90
+ const lastSuccessfulCheckRef = useRef(getACLCheckReady(app));
88
91
  const pathnameRef = useRef(location.pathname);
89
92
  pathnameRef.current = location.pathname;
90
93
 
91
94
  const refresh = useCallback(async () => {
92
95
  if (app.router.isSkippedAuthCheckRoute(pathnameRef.current)) {
93
96
  // 认证页等免鉴权路由不需要执行 `roles:check`,避免未登录时产生多余的 401 与 loading 闪烁。
97
+ refreshGenerationRef.current += 1;
98
+ lastSuccessfulCheckRef.current = false;
99
+ setACLCheckReady(app, false);
94
100
  setLoading(false);
95
101
  return;
96
102
  }
97
103
 
104
+ const refreshGeneration = ++refreshGenerationRef.current;
105
+ setACLCheckReady(app, false);
98
106
  const shouldShowLoading = !aclStore.data?.role && !aclStore.data?.snippets?.length;
99
107
  if (shouldShowLoading) {
100
108
  setLoading(true);
@@ -110,6 +118,10 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
110
118
  const nextData = res?.data?.data || {};
111
119
  const nextMeta = res?.data?.meta || {};
112
120
 
121
+ if (refreshGeneration !== refreshGenerationRef.current) {
122
+ return;
123
+ }
124
+
113
125
  aclStore.setData(nextData);
114
126
  aclStore.setMeta(nextMeta);
115
127
  app.pluginSettingsManager.setAclSnippets(nextData?.snippets || []);
@@ -118,14 +130,22 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
118
130
  app.apiClient.auth.setRole(nextData?.role);
119
131
  }
120
132
 
133
+ lastSuccessfulCheckRef.current = true;
134
+ setACLCheckReady(app, true);
135
+
121
136
  if (!createAclSnippetAllow(nextData?.snippets || [], !!nextData?.allowAll)('ui.*')) {
122
137
  writeFlowSettingsPreference(false);
123
138
  await app.flowEngine.flowSettings.disable();
124
139
  }
125
140
  } catch (error) {
141
+ if (refreshGeneration !== refreshGenerationRef.current) {
142
+ return;
143
+ }
144
+
126
145
  const status = error?.response?.status || error?.status;
127
146
 
128
147
  if (status === 401) {
148
+ lastSuccessfulCheckRef.current = false;
129
149
  aclStore.setData({});
130
150
  aclStore.setMeta({});
131
151
  app.pluginSettingsManager.setAclSnippets([]);
@@ -133,15 +153,24 @@ export const ACLRolesCheckProvider: FC = ({ children }) => {
133
153
  return;
134
154
  }
135
155
 
156
+ setACLCheckReady(app, lastSuccessfulCheckRef.current);
136
157
  console.error(error);
137
158
  } finally {
138
- setLoading(false);
159
+ if (refreshGeneration === refreshGenerationRef.current) {
160
+ setLoading(false);
161
+ }
139
162
  }
140
163
  }, [aclStore, app]);
141
164
 
142
165
  useEffect(() => {
143
- void refresh();
144
- }, [refresh]);
166
+ refresh();
167
+
168
+ return () => {
169
+ refreshGenerationRef.current += 1;
170
+ lastSuccessfulCheckRef.current = false;
171
+ setACLCheckReady(app, false);
172
+ };
173
+ }, [app, refresh]);
145
174
 
146
175
  const value = useMemo<ACLContextValue>(
147
176
  () => ({
@@ -169,6 +198,27 @@ export const useACLContext = () => {
169
198
  return useContext(ACLContext);
170
199
  };
171
200
 
201
+ /**
202
+ * 读取当前角色的 snippets,且不要求调用方位于 `ACLRolesCheckProvider` 之内。
203
+ *
204
+ * 设置中心顶栏导航挂在 app 级 provider 上,位置比 `ACLRolesCheckProvider` 更外层,
205
+ * 这种情况下退回到 app context 上的 ACL observable store。
206
+ * 调用方需要用 `observer` 包裹,store 更新后才会重新渲染。
207
+ *
208
+ * @returns {string[]} 当前角色的 snippets
209
+ */
210
+ export const useACLSnippets = (): string[] => {
211
+ const app = useApp();
212
+ const ctx = useACLContext();
213
+ const contextSnippets = ctx?.data?.data?.snippets;
214
+ // 必须走 ensureACLStore:调用方可能位于 `ACLRolesCheckProvider` 之外,此时 store 还没建,
215
+ // 直接读 `app.context.acl` 拿到的是 undefined,observer 就订阅不到后续的权限写入,
216
+ // 会一直停在首帧的空 snippets 上。
217
+ const storeSnippets = ensureACLStore(app)?.data?.snippets;
218
+
219
+ return contextSnippets?.length ? contextSnippets : storeSnippets || [];
220
+ };
221
+
172
222
  /**
173
223
  * 触发当前角色权限重新检查。
174
224
  */
@@ -0,0 +1,44 @@
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 { useEffect, useState } from 'react';
11
+
12
+ type ReadinessListener = (ready: boolean) => void;
13
+
14
+ const readinessByApp = new WeakMap<object, boolean>();
15
+ const listenersByApp = new WeakMap<object, Set<ReadinessListener>>();
16
+
17
+ export function getACLCheckReady(app: object) {
18
+ return readinessByApp.get(app) ?? false;
19
+ }
20
+
21
+ export function setACLCheckReady(app: object, ready: boolean) {
22
+ readinessByApp.set(app, ready);
23
+ listenersByApp.get(app)?.forEach((listener) => listener(ready));
24
+ }
25
+
26
+ export function useACLCheckReady(app: object) {
27
+ const [ready, setReady] = useState(() => getACLCheckReady(app));
28
+
29
+ useEffect(() => {
30
+ const listeners = listenersByApp.get(app) || new Set<ReadinessListener>();
31
+ listeners.add(setReady);
32
+ listenersByApp.set(app, listeners);
33
+ setReady(getACLCheckReady(app));
34
+
35
+ return () => {
36
+ listeners.delete(setReady);
37
+ if (!listeners.size) {
38
+ listenersByApp.delete(app);
39
+ }
40
+ };
41
+ }, [app]);
42
+
43
+ return ready;
44
+ }
@@ -8,8 +8,19 @@
8
8
  */
9
9
 
10
10
  import type { BaseApplication } from './BaseApplication';
11
+ import {
12
+ resolveSettingsAppScope,
13
+ resolveSettingsAppScopeWithinPublicPath,
14
+ resolveSettingsDocumentPath,
15
+ type SettingsAppScope,
16
+ } from './settings-app/settingsDocumentPath';
11
17
 
12
18
  type AppLike = Pick<BaseApplication<any>, 'getPublicPath'> & {
19
+ name?: string;
20
+ pluginSettingsManager?: {
21
+ getRouteName?: (name: string) => string;
22
+ getRoutePath?: (name: string) => string;
23
+ };
13
24
  router?: {
14
25
  getBasename?: () => string | undefined;
15
26
  };
@@ -52,7 +63,8 @@ function normalizePublicPath(value?: string) {
52
63
  }
53
64
 
54
65
  function normalizePathname(value?: string) {
55
- const normalized = ensureLeadingSlash(String(value || '/').trim() || '/').replace(/\/{2,}/g, '/');
66
+ const pathname = ensureLeadingSlash(String(value || '/').trim() || '/');
67
+ const normalized = new URL(pathname, 'http://nocobase.local').pathname.replace(/\/{2,}/g, '/');
56
68
  if (normalized !== '/' && normalized.endsWith('/')) {
57
69
  return trimTrailingSlashes(normalized);
58
70
  }
@@ -179,7 +191,53 @@ function joinRootRelativePath(basePath: string, pathname: string) {
179
191
  return normalizePathname(`/${trimLeadingSlashes(normalizedBasePath)}/${trimLeadingSlashes(normalizedPathname)}`);
180
192
  }
181
193
 
194
+ function isStandaloneSettingsApp(app: AppLike) {
195
+ return app.pluginSettingsManager?.getRouteName?.('') === 'settings.';
196
+ }
197
+
198
+ function getSettingsRootPublicPath(app: AppLike) {
199
+ const publicPath = normalizePublicPath(app.getPublicPath());
200
+ if (isStandaloneSettingsApp(app)) {
201
+ return publicPath;
202
+ }
203
+
204
+ const segments = trimTrailingSlashes(publicPath).split('/');
205
+ segments.pop();
206
+ return normalizePublicPath(segments.join('/') || '/');
207
+ }
208
+
209
+ function getSettingsAppScope(app: AppLike): SettingsAppScope {
210
+ const publicPath = app.getPublicPath();
211
+ return (
212
+ resolveSettingsAppScopeWithinPublicPath(publicPath, app.router?.getBasename?.()) ||
213
+ (app.name && app.name !== 'main' ? resolveSettingsAppScope(`/apps/${app.name}`) : '')
214
+ );
215
+ }
216
+
217
+ function getStandaloneSettingsBasePath(app: AppLike) {
218
+ return resolveSettingsDocumentPath(getSettingsRootPublicPath(app), getSettingsAppScope(app), '/settings');
219
+ }
220
+
221
+ function isStandaloneSettingsTarget(app: AppLike, pathname: string) {
222
+ const normalizedPathname = normalizePathname(pathname);
223
+ const settingsBasePath = normalizePathname(getStandaloneSettingsBasePath(app));
224
+ return normalizedPathname === settingsBasePath || normalizedPathname.startsWith(`${settingsBasePath}/`);
225
+ }
226
+
227
+ function isSettingsTargetForAnotherApp(app: AppLike, pathname: string) {
228
+ const normalizedPathname = normalizePathname(pathname);
229
+ const rootPublicPath = trimTrailingSlashes(getSettingsRootPublicPath(app)) || '';
230
+ const pathWithinRoot =
231
+ rootPublicPath && normalizedPathname.startsWith(`${rootPublicPath}/`)
232
+ ? normalizedPathname.slice(rootPublicPath.length)
233
+ : normalizedPathname;
234
+ return /^\/settings(?:\/|$)/.test(pathWithinRoot);
235
+ }
236
+
182
237
  function getV2SigninPath(app: AppLike) {
238
+ if (isStandaloneSettingsApp(app)) {
239
+ return joinRootRelativePath(getStandaloneSettingsBasePath(app), '/signin');
240
+ }
183
241
  return joinRootRelativePath(getV2EffectiveBasePath(app), '/signin');
184
242
  }
185
243
 
@@ -204,7 +262,7 @@ function getDefaultV2AdminRedirectPath(app: AppLike) {
204
262
  return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
205
263
  }
206
264
 
207
- function isSafeRootRelativePath(value?: string | null) {
265
+ function isSafeRootRelativePath(value?: string | null): value is string {
208
266
  return !!value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
209
267
  }
210
268
 
@@ -224,6 +282,10 @@ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fa
224
282
  let { pathname, search, hash } = splitPathLike(rawTarget);
225
283
  let normalizedPathname = normalizePathname(pathname);
226
284
 
285
+ if (isStandaloneSettingsTarget(app, normalizedPathname)) {
286
+ return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
287
+ }
288
+
227
289
  // Already under the current v2 runtime, e.g. `/v/apps/a/admin/`.
228
290
  if (basePath === '/' || normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
229
291
  return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
@@ -237,6 +299,11 @@ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fa
237
299
  normalizedPathname = normalizePathname(pathname);
238
300
  }
239
301
 
302
+ if (isSettingsTargetForAnotherApp(app, normalizedPathname)) {
303
+ ({ pathname, search, hash } = splitPathLike(fallbackTarget));
304
+ normalizedPathname = normalizePathname(pathname);
305
+ }
306
+
240
307
  // Basename-relative target, e.g. `/admin/`, becomes `/v/apps/a/admin/`.
241
308
  const joinedPathname = preserveTrailingSlash(pathname, joinRootRelativePath(basePath, normalizedPathname));
242
309
  return `${joinedPathname}${normalizeSearch(search)}${normalizeHash(hash)}`;
@@ -261,10 +328,14 @@ export function getCurrentV2RedirectPath(app: AppLike, locationLike: LocationLik
261
328
  *
262
329
  * @param app 当前 v2 应用实例
263
330
  * @param targetPath 登录后回跳地址
331
+ * @param options 可选的登录路由配置
264
332
  * @returns 指向 v2 登录页的 href
265
333
  */
266
- export function buildV2SigninHref(app: AppLike, targetPath: string) {
267
- return `${getV2SigninPath(app)}?redirect=${encodeURIComponent(targetPath)}`;
334
+ export function buildV2SigninHref(app: AppLike, targetPath: string, options?: { signInRoutePath?: string }) {
335
+ const signInPath = options?.signInRoutePath
336
+ ? normalizeV2RedirectPath(app, options.signInRoutePath, options.signInRoutePath)
337
+ : getV2SigninPath(app);
338
+ return `${signInPath}?redirect=${encodeURIComponent(targetPath)}`;
268
339
  }
269
340
 
270
341
  /**
@@ -272,10 +343,14 @@ export function buildV2SigninHref(app: AppLike, targetPath: string) {
272
343
  *
273
344
  * @param app 当前 v2 应用实例
274
345
  * @param targetPath 登录后回跳地址
275
- * @param options 跳转选项
346
+ * @param options 跳转和登录路由选项
276
347
  */
277
- export function redirectToV2Signin(app: AppLike, targetPath: string, options?: { replace?: boolean }) {
278
- const href = buildV2SigninHref(app, targetPath);
348
+ export function redirectToV2Signin(
349
+ app: AppLike,
350
+ targetPath: string,
351
+ options?: { replace?: boolean; signInRoutePath?: string },
352
+ ) {
353
+ const href = buildV2SigninHref(app, targetPath, options);
279
354
  if (options?.replace === false) {
280
355
  window.location.href = href;
281
356
  return;
@@ -306,7 +381,9 @@ export function resolveV2SigninRedirect(value: string | undefined | null, app: A
306
381
  return null;
307
382
  }
308
383
 
309
- const validPathnames = new Set(['/signin', getV2SigninPath(app)]);
384
+ const validPathnames = new Set(
385
+ isStandaloneSettingsApp(app) ? [getV2SigninPath(app)] : ['/signin', getV2SigninPath(app)],
386
+ );
310
387
  if (!validPathnames.has(url.pathname)) {
311
388
  return null;
312
389
  }
@@ -28,9 +28,11 @@ interface AppErrorPayload {
28
28
  maintainingDialog?: string;
29
29
  };
30
30
  };
31
- [key: string]: any;
32
31
  }
33
32
 
33
+ const isAppCommanding = (error?: AppErrorPayload) => error?.code === 'APP_COMMANDING';
34
+ const isAppUpgrading = (error?: AppErrorPayload) => isAppCommanding(error) && error?.command?.name === 'upgrade';
35
+
34
36
  export const AppSpin = () => {
35
37
  return (
36
38
  <Spin style={{ position: 'fixed', top: '50%', left: '50%', fontSize: 72, transform: 'translate(-50%, -50%)' }} />
@@ -168,6 +170,7 @@ const getProps = (app: Application) => {
168
170
  export const AppError: FC<{ error: Error & { title?: string }; app: Application }> = observer(
169
171
  ({ app, error }) => {
170
172
  const props = getProps(app);
173
+ const commanding = isAppCommanding(app.error as AppErrorPayload | undefined);
171
174
  return (
172
175
  <div>
173
176
  <Result
@@ -180,11 +183,13 @@ export const AppError: FC<{ error: Error & { title?: string }; app: Application
180
183
  status="error"
181
184
  title={error?.title || app.i18n.t('App error', { ns: 'client' })}
182
185
  subTitle={app.i18n.t(error?.message)}
183
- extra={[
184
- <Button type="primary" key="try" onClick={() => window.location.reload()}>
185
- {app.i18n.t('Try again')}
186
- </Button>,
187
- ]}
186
+ extra={
187
+ commanding ? null : (
188
+ <Button type="primary" key="try" onClick={() => window.location.reload()}>
189
+ {app.i18n.t('Try again')}
190
+ </Button>
191
+ )
192
+ }
188
193
  {...props}
189
194
  />
190
195
  </div>
@@ -223,10 +228,14 @@ export const AppMaintaining: FC<{ app: Application; error: Error }> = observer(
223
228
 
224
229
  export const AppMaintainingDialog: FC<{ app: Application; error: Error }> = observer(
225
230
  ({ app, error }) => {
226
- const component = (error as AppErrorPayload | undefined)?.command?.components?.maintainingDialog;
231
+ const payload = error as AppErrorPayload | undefined;
232
+ const component = payload?.command?.components?.maintainingDialog;
227
233
  if (component) {
228
234
  return app.renderComponent(component, { app, error });
229
235
  }
236
+ if (isAppUpgrading(payload)) {
237
+ return null;
238
+ }
230
239
 
231
240
  const { icon, status, title, subTitle } = getProps(app);
232
241
  return (
@@ -0,0 +1,275 @@
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 { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
11
+ import { css, cx } from '@emotion/css';
12
+ import { Upload, message } from 'antd';
13
+ import type { UploadFile } from 'antd';
14
+ import mime from 'mime';
15
+ import match from 'mime-match';
16
+ import type { RcFile, UploadRequestOption } from 'rc-upload/lib/interface';
17
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { useTranslation } from 'react-i18next';
19
+ import { useApp } from '../hooks/useApp';
20
+
21
+ export type UploadedAttachment = {
22
+ id?: number;
23
+ title?: string;
24
+ filename?: string;
25
+ url?: string;
26
+ };
27
+
28
+ export type AttachmentStorageRules = {
29
+ size?: number;
30
+ mimetype?: string | string[];
31
+ };
32
+
33
+ /**
34
+ * 读取默认附件存储的上传限制。
35
+ *
36
+ * 校验交给存储自身的配置(大小、mimetype),而不是在每个调用点各写一套硬编码规则。
37
+ *
38
+ * @returns {AttachmentStorageRules | null} 存储规则,取不到时为 null
39
+ */
40
+ export function useAttachmentStorageRules() {
41
+ const app = useApp();
42
+ const [storageRules, setStorageRules] = useState<AttachmentStorageRules | null>(null);
43
+
44
+ useEffect(() => {
45
+ let mounted = true;
46
+
47
+ const loadStorageRules = async () => {
48
+ try {
49
+ const attachmentsCollection = app.dataSourceManager?.getCollection?.('main', 'attachments');
50
+ const storageName = attachmentsCollection?.getOption?.('storage');
51
+
52
+ if (!storageName) {
53
+ if (mounted) {
54
+ setStorageRules(null);
55
+ }
56
+ return;
57
+ }
58
+
59
+ const response = await app.apiClient.request({
60
+ url: `storages:getBasicInfo/${storageName}`,
61
+ skipNotify: true,
62
+ });
63
+
64
+ if (mounted) {
65
+ setStorageRules(response?.data?.data?.rules || null);
66
+ }
67
+ } catch (error) {
68
+ if (mounted) {
69
+ setStorageRules(null);
70
+ }
71
+ }
72
+ };
73
+
74
+ loadStorageRules().catch(() => {
75
+ if (mounted) {
76
+ setStorageRules(null);
77
+ }
78
+ });
79
+
80
+ return () => {
81
+ mounted = false;
82
+ };
83
+ }, [app]);
84
+
85
+ return storageRules;
86
+ }
87
+
88
+ export type AttachmentUploadProps = {
89
+ value?: UploadedAttachment | null;
90
+ onChange?: (value: UploadedAttachment | null) => void;
91
+ /** 卡片尺寸;`fit` 对应 CSS object-fit */
92
+ preview?: {
93
+ width?: number;
94
+ height?: number;
95
+ fit?: 'contain' | 'cover';
96
+ };
97
+ /** 在存储规则之外再收窄可选文件类型,例如只收图片 */
98
+ accept?: string;
99
+ /** 上传卡片里 `+` 下方的文字 */
100
+ uploadText?: string;
101
+ disabled?: boolean;
102
+ };
103
+
104
+ const DEFAULT_PREVIEW = { width: 104, height: 104, fit: 'contain' as const };
105
+
106
+ /**
107
+ * 附件上传控件。
108
+ *
109
+ * 受控组件,值就是 `attachments:create` 返回的附件对象,可直接挂在 `Form.Item` 上。
110
+ * 外观沿用附件字段的原生形态:一张 picture-card,删除在卡片自身的悬浮操作里,不额外摆按钮。
111
+ * 上传前按默认存储的规则校验大小与 mimetype,与业务表单里的附件字段保持同一套限制。
112
+ */
113
+ export const AttachmentUpload: React.FC<AttachmentUploadProps> = (props) => {
114
+ const { value, onChange, preview, accept, uploadText, disabled } = props;
115
+ const app = useApp();
116
+ const { t } = useTranslation();
117
+ const [uploading, setUploading] = useState(false);
118
+ const storageRules = useAttachmentStorageRules();
119
+ const previewConfig = { ...DEFAULT_PREVIEW, ...preview };
120
+
121
+ const acceptValue = useMemo(() => {
122
+ if (accept) {
123
+ return accept;
124
+ }
125
+ const rule = storageRules?.mimetype;
126
+ if (!rule) {
127
+ return undefined;
128
+ }
129
+ return Array.isArray(rule) ? rule.join(',') : rule;
130
+ }, [accept, storageRules?.mimetype]);
131
+
132
+ const beforeUpload = useCallback(
133
+ async (file: RcFile) => {
134
+ const sizeLimit = storageRules?.size;
135
+
136
+ if (typeof sizeLimit === 'number' && sizeLimit > 0 && file.size > sizeLimit) {
137
+ message.error(t('File size exceeds the limit'));
138
+ return Upload.LIST_IGNORE;
139
+ }
140
+
141
+ const mimetypeRule = storageRules?.mimetype;
142
+ const normalizedRule = Array.isArray(mimetypeRule)
143
+ ? mimetypeRule.join(',').trim()
144
+ : `${mimetypeRule || ''}`.trim();
145
+ const acceptRule = accept?.trim();
146
+ const rules = [normalizedRule, acceptRule].filter((rule) => rule && rule !== '*');
147
+
148
+ if (!rules.length) {
149
+ return file;
150
+ }
151
+
152
+ // 有些浏览器对不常见扩展名给不出 type,这里按扩展名补一次再判断。
153
+ let targetFile = file;
154
+ if (!targetFile.type) {
155
+ const extname = targetFile.name?.match(/\.[^.]+$/)?.[0];
156
+ if (extname) {
157
+ targetFile = new File([targetFile], targetFile.name, {
158
+ type: mime.getType(extname) || 'application/octet-stream',
159
+ lastModified: targetFile.lastModified,
160
+ }) as RcFile;
161
+ }
162
+ }
163
+
164
+ const isAllowed = rules.every((rule) =>
165
+ rule
166
+ .split(',')
167
+ .filter(Boolean)
168
+ .some((item) => match(targetFile.type)(item.trim())),
169
+ );
170
+
171
+ if (!isAllowed) {
172
+ message.error(t('File type is not allowed'));
173
+ return Upload.LIST_IGNORE;
174
+ }
175
+
176
+ return targetFile;
177
+ },
178
+ [accept, storageRules?.mimetype, storageRules?.size, t],
179
+ );
180
+
181
+ const handleUpload = useCallback(
182
+ async (options: UploadRequestOption) => {
183
+ const { file, onError, onSuccess } = options;
184
+ setUploading(true);
185
+
186
+ try {
187
+ const formData = new FormData();
188
+ formData.append('file', file as RcFile);
189
+
190
+ const response = await app.apiClient.request({
191
+ url: 'attachments:create',
192
+ method: 'post',
193
+ data: formData,
194
+ });
195
+
196
+ onChange?.(response?.data?.data || null);
197
+ onSuccess?.(response?.data, file as RcFile);
198
+ } catch (error) {
199
+ onError?.(error as Error);
200
+ } finally {
201
+ setUploading(false);
202
+ }
203
+ },
204
+ [app.apiClient, onChange],
205
+ );
206
+
207
+ const fileList = useMemo<UploadFile[]>(() => {
208
+ if (!value?.url) {
209
+ return [];
210
+ }
211
+
212
+ return [
213
+ {
214
+ uid: `${value.id ?? value.url}`,
215
+ name: value.title || value.filename || '',
216
+ status: 'done',
217
+ url: value.url,
218
+ thumbUrl: value.url,
219
+ },
220
+ ];
221
+ }, [value?.filename, value?.id, value?.title, value?.url]);
222
+
223
+ const handlePreview = useCallback((file: UploadFile) => {
224
+ if (file.url) {
225
+ window.open(file.url, '_blank', 'noopener,noreferrer');
226
+ }
227
+ }, []);
228
+
229
+ const handleRemove = useCallback(() => {
230
+ onChange?.(null);
231
+ return true;
232
+ }, [onChange]);
233
+
234
+ // picture-card 的卡片尺寸写死在 104×104,这里按调用方要的比例覆盖掉。
235
+ const sizeClassName = useMemo(
236
+ () => css`
237
+ .ant-upload.ant-upload-select,
238
+ .ant-upload-list-item-container {
239
+ width: ${previewConfig.width}px !important;
240
+ height: ${previewConfig.height}px !important;
241
+ }
242
+
243
+ .ant-upload-list-item-thumbnail img {
244
+ object-fit: ${previewConfig.fit} !important;
245
+ }
246
+ `,
247
+ [previewConfig.fit, previewConfig.height, previewConfig.width],
248
+ );
249
+
250
+ return (
251
+ <Upload
252
+ accept={acceptValue}
253
+ beforeUpload={beforeUpload}
254
+ className={cx('nb-attachment-upload', sizeClassName)}
255
+ customRequest={handleUpload}
256
+ // 上传期间一并禁用:否则可以在第一个请求返回前再选一个文件,
257
+ // 两个请求谁后返回谁覆盖 onChange,最终留下的不一定是用户最后选的那张。
258
+ disabled={disabled || uploading}
259
+ fileList={fileList}
260
+ listType="picture-card"
261
+ maxCount={1}
262
+ onPreview={handlePreview}
263
+ onRemove={handleRemove}
264
+ >
265
+ {fileList.length ? null : (
266
+ <div>
267
+ {uploading ? <LoadingOutlined /> : <PlusOutlined />}
268
+ <div style={{ marginTop: 8 }}>{uploadText || t('Upload')}</div>
269
+ </div>
270
+ )}
271
+ </Upload>
272
+ );
273
+ };
274
+
275
+ export default AttachmentUpload;
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  export * from './AppComponents';
11
+ export * from './AttachmentUpload';
11
12
  export * from './BlankComponent';
12
13
  export * from './category-tabs';
13
14
  export * from './form/table/dnd';