@nocobase/client-v2 2.3.0-beta.5 → 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.
- 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
package/src/acl/ACLProvider.tsx
CHANGED
|
@@ -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
|
-
|
|
159
|
+
if (refreshGeneration === refreshGenerationRef.current) {
|
|
160
|
+
setLoading(false);
|
|
161
|
+
}
|
|
139
162
|
}
|
|
140
163
|
}, [aclStore, app]);
|
|
141
164
|
|
|
142
165
|
useEffect(() => {
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
}
|
package/src/authRedirect.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
278
|
-
|
|
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(
|
|
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
|
-
|
|
185
|
-
{
|
|
186
|
-
|
|
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
|
|
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;
|