-
-
-
-
-
- {render()}
-
-
+
+
+
+
+
+
+ {render()}
+
-
+
);
};
diff --git a/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx b/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx
index 01654a8784..05fecbda1a 100644
--- a/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx
+++ b/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx
@@ -8,26 +8,10 @@
*/
import { DocumentTitleProvider, Form, FormItem, Grid, IconPicker, Input } from '@nocobase/client';
-import { render, renderAppOptions, screen, userEvent, waitFor } from '@nocobase/test/client';
-import React from 'react';
-import App1 from '../demos/demo1';
+import { renderAppOptions, screen, userEvent, waitFor } from '@nocobase/test/client';
import { isTabPage, navigateToTab, Page } from '../Page';
describe('Page', () => {
- it('should render correctly', async () => {
- render(
);
-
- await waitFor(() => {
- expect(screen.getByText(/page title/i)).toBeInTheDocument();
- });
- await waitFor(() => {
- expect(screen.getByText(/page content/i)).toBeInTheDocument();
- });
- await waitFor(() => {
- expect(document.title).toBe('Page Title - NocoBase');
- });
- });
-
describe('Page Component', () => {
const title = 'Test Title';
diff --git a/packages/core/client/src/schema-component/antd/page/demos/demo1.tsx b/packages/core/client/src/schema-component/antd/page/demos/demo1.tsx
deleted file mode 100644
index e947ddbc50..0000000000
--- a/packages/core/client/src/schema-component/antd/page/demos/demo1.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-import { ISchema } from '@formily/react';
-import { DocumentTitleProvider, Page, SchemaComponent, SchemaComponentProvider, Application } from '@nocobase/client';
-import React from 'react';
-
-const schema: ISchema = {
- type: 'object',
- properties: {
- page1: {
- type: 'void',
- 'x-component': 'Page',
- title: 'Page Title',
- properties: {
- content: {
- type: 'void',
- 'x-component': 'div',
- 'x-content': 'Page Content',
- },
- },
- },
- },
-};
-
-const Root = () => {
- return (
-
-
-
-
-
- );
-};
-
-const app = new Application({
- providers: [Root],
-});
-
-export default app.getRootComponent();
diff --git a/packages/core/client/src/schema-component/core/RemoteSchemaComponent.tsx b/packages/core/client/src/schema-component/core/RemoteSchemaComponent.tsx
index b42eac095c..356705c31b 100644
--- a/packages/core/client/src/schema-component/core/RemoteSchemaComponent.tsx
+++ b/packages/core/client/src/schema-component/core/RemoteSchemaComponent.tsx
@@ -84,4 +84,5 @@ const RequestSchemaComponent: React.FC
= (props) =>
export const RemoteSchemaComponent: React.FC = memo((props) => {
return props.uid ? : null;
});
+
RemoteSchemaComponent.displayName = 'RemoteSchemaComponent';
diff --git a/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx b/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx
index 3f41bf55ec..7ba10563d5 100644
--- a/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx
+++ b/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx
@@ -17,13 +17,14 @@ import React, {
FC,
startTransition,
useCallback,
+ useContext,
useEffect,
useMemo,
useRef,
useState,
- useContext,
} from 'react';
import { useTranslation } from 'react-i18next';
+import { SchemaComponentContext } from '../';
import { SchemaInitializer, SchemaSettings, SchemaToolbarProvider, useSchemaInitializerRender } from '../application';
import { useSchemaSettingsRender } from '../application/schema-settings/hooks/useSchemaSettingsRender';
import { useDataSourceManager } from '../data-source/data-source/DataSourceManagerProvider';
@@ -33,7 +34,6 @@ import { DragHandler, useCompile, useDesignable, useGridContext, useGridRowConte
import { gridRowColWrap } from '../schema-initializer/utils';
import { SchemaSettingsDropdown } from './SchemaSettings';
import { useGetAriaLabelOfDesigner } from './hooks/useGetAriaLabelOfDesigner';
-import { SchemaComponentContext } from '../';
import { useStyles } from './styles';
const titleCss = css`
@@ -208,6 +208,11 @@ export interface SchemaToolbarProps {
spaceWrapperStyle?: React.CSSProperties;
spaceClassName?: string;
spaceStyle?: React.CSSProperties;
+ /**
+ * The HTML element that listens for mouse enter/leave events.
+ * Parent element is used by default.
+ */
+ container?: HTMLElement;
onVisibleChange?: (nextVisible: boolean) => void;
}
@@ -226,6 +231,7 @@ const InternalSchemaToolbar: FC = React.memo((props) => {
spaceStyle,
toolbarClassName,
toolbarStyle = {},
+ container,
} = {
...props,
...(fieldSchema?.['x-toolbar-props'] || {}),
@@ -312,7 +318,10 @@ const InternalSchemaToolbar: FC = React.memo((props) => {
while (parentElement && parentElement.clientHeight === 0) {
parentElement = parentElement.parentElement;
}
- if (!parentElement) {
+
+ const el = container || parentElement;
+
+ if (!el) {
return;
}
@@ -330,18 +339,18 @@ const InternalSchemaToolbar: FC = React.memo((props) => {
}
}
- const style = window.getComputedStyle(parentElement);
- if (style.position === 'static') {
- parentElement.style.position = 'relative';
- }
+ // const style = window.getComputedStyle(parentElement);
+ // if (style.position === 'static') {
+ // parentElement.style.position = 'relative';
+ // }
- parentElement.addEventListener('mouseenter', show);
- parentElement.addEventListener('mouseleave', hide);
+ el.addEventListener('mouseenter', show);
+ el.addEventListener('mouseleave', hide);
return () => {
- parentElement.removeEventListener('mouseenter', show);
- parentElement.removeEventListener('mouseleave', hide);
+ el.removeEventListener('mouseenter', show);
+ el.removeEventListener('mouseleave', hide);
};
- }, [props.onVisibleChange]);
+ }, [props.onVisibleChange, container]);
const containerStyle = useMemo(
() => ({
diff --git a/packages/core/client/src/schema-settings/hooks/useGetAriaLabelOfDesigner.ts b/packages/core/client/src/schema-settings/hooks/useGetAriaLabelOfDesigner.ts
index bcb54d9e51..a0cdc9d51d 100644
--- a/packages/core/client/src/schema-settings/hooks/useGetAriaLabelOfDesigner.ts
+++ b/packages/core/client/src/schema-settings/hooks/useGetAriaLabelOfDesigner.ts
@@ -20,7 +20,7 @@ export const useGetAriaLabelOfDesigner = () => {
const { name: _collectionName } = useCollection_deprecated();
const getAriaLabel = useCallback(
(name: string, postfix?: string) => {
- if (!fieldSchema) return '';
+ if (!fieldSchema) return `designer-${name}-${postfix}`;
const component = fieldSchema['x-component'];
const componentName = typeof component === 'string' ? component : component?.displayName || component?.name;
diff --git a/packages/core/test/src/e2e/e2eUtils.ts b/packages/core/test/src/e2e/e2eUtils.ts
index f3a46dd8ce..0f2a11c16d 100644
--- a/packages/core/test/src/e2e/e2eUtils.ts
+++ b/packages/core/test/src/e2e/e2eUtils.ts
@@ -13,6 +13,24 @@ import { Browser, Page, test as base, expect, request } from '@playwright/test';
import _ from 'lodash';
import { defineConfig } from './defineConfig';
+function getPageMenuSchema({ pageSchemaUid, tabSchemaUid, tabSchemaName }) {
+ return {
+ type: 'void',
+ 'x-component': 'Page',
+ properties: {
+ [tabSchemaName]: {
+ type: 'void',
+ 'x-component': 'Grid',
+ 'x-initializer': 'page:addBlock',
+ properties: {},
+ 'x-uid': tabSchemaUid,
+ 'x-async': true,
+ },
+ },
+ 'x-uid': pageSchemaUid,
+ };
+}
+
export * from '@playwright/test';
export { defineConfig };
@@ -360,7 +378,7 @@ export class NocoPage {
this.uid = schemaUid;
this.desktopRouteId = routeId;
- this.url = `${this.options?.basePath || '/admin/'}${this.uid}`;
+ this.url = `${this.options?.basePath || '/admin/'}${this.uid || this.desktopRouteId}`;
}
async goto() {
@@ -393,7 +411,7 @@ export class NocoPage {
async destroy() {
const waitList: any[] = [];
- if (this.uid) {
+ if (this.uid || this.desktopRouteId !== undefined) {
waitList.push(deletePage(this.uid, this.desktopRouteId));
this.uid = undefined;
this.desktopRouteId = undefined;
@@ -723,30 +741,15 @@ const createPage = async (options?: CreatePageOptions) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
- const typeToSchema = {
- group: {
- 'x-component': 'Menu.SubMenu',
- 'x-component-props': {},
- },
- page: {
- 'x-component': 'Menu.Item',
- 'x-component-props': {},
- },
- link: {
- 'x-component': 'Menu.URL',
- 'x-component-props': {
- href: url,
- },
- },
- };
const state = await api.storageState();
const headers = getHeaders(state);
- const menuSchemaUid = pageUidFromOptions || uid();
- const pageSchemaUid = uid();
- const tabSchemaUid = uid();
- const tabSchemaName = uid();
- const title = name || menuSchemaUid;
const newPageSchema = keepUid ? pageSchema : updateUidOfPageSchema(pageSchema);
+ const pageSchemaUid = newPageSchema?.['x-uid'] || uid();
+ const newTabSchemaUid = uid();
+ const newTabSchemaName = uid();
+
+ const title = name || pageSchemaUid;
+
let routeId;
let schemaUid;
@@ -756,7 +759,6 @@ const createPage = async (options?: CreatePageOptions) => {
data: {
type: 'group',
title,
- schemaUid: menuSchemaUid,
hideInMenu: false,
},
});
@@ -767,17 +769,15 @@ const createPage = async (options?: CreatePageOptions) => {
const data = await result.json();
routeId = data.data?.id;
- schemaUid = menuSchemaUid;
}
if (type === 'page') {
- const result = await api.post('/api/desktopRoutes:create', {
+ const routeResult = await api.post('/api/desktopRoutes:create', {
headers,
data: {
type: 'page',
title,
- schemaUid: newPageSchema?.['x-uid'] || pageSchemaUid,
- menuSchemaUid,
+ schemaUid: pageSchemaUid,
hideInMenu: false,
enableTabs: !!newPageSchema?.['x-component-props']?.enablePageTabs,
children: newPageSchema
@@ -786,21 +786,36 @@ const createPage = async (options?: CreatePageOptions) => {
{
type: 'tabs',
title: '{{t("Unnamed")}}',
- schemaUid: tabSchemaUid,
- tabSchemaName,
+ schemaUid: newTabSchemaUid,
+ tabSchemaName: newTabSchemaName,
hideInMenu: false,
},
],
},
});
- if (!result.ok()) {
- throw new Error(await result.text());
+ if (!routeResult.ok()) {
+ throw new Error(await routeResult.text());
}
- const data = await result.json();
+ const schemaResult = await api.post(`/api/uiSchemas:insert`, {
+ headers,
+ data:
+ newPageSchema ||
+ getPageMenuSchema({
+ pageSchemaUid,
+ tabSchemaUid: newTabSchemaUid,
+ tabSchemaName: newTabSchemaName,
+ }),
+ });
+
+ if (!schemaResult.ok()) {
+ throw new Error(await routeResult.text());
+ }
+
+ const data = await routeResult.json();
routeId = data.data?.id;
- schemaUid = menuSchemaUid;
+ schemaUid = pageSchemaUid;
}
if (type === 'link') {
@@ -809,7 +824,6 @@ const createPage = async (options?: CreatePageOptions) => {
data: {
type: 'link',
title,
- schemaUid: menuSchemaUid,
hideInMenu: false,
options: {
href: url,
@@ -823,50 +837,6 @@ const createPage = async (options?: CreatePageOptions) => {
const data = await result.json();
routeId = data.data?.id;
- schemaUid = menuSchemaUid;
- }
-
- const result = await api.post(`/api/uiSchemas:insertAdjacent/nocobase-admin-menu?position=beforeEnd`, {
- headers,
- data: {
- schema: {
- _isJSONSchemaObject: true,
- version: '2.0',
- type: 'void',
- title,
- ...typeToSchema[type],
- 'x-decorator': 'ACLMenuItemProvider',
- properties: {
- page: newPageSchema || {
- _isJSONSchemaObject: true,
- version: '2.0',
- type: 'void',
- 'x-component': 'Page',
- 'x-async': true,
- properties: {
- [tabSchemaName]: {
- _isJSONSchemaObject: true,
- version: '2.0',
- type: 'void',
- 'x-component': 'Grid',
- 'x-initializer': 'page:addBlock',
- 'x-uid': tabSchemaUid,
- name: tabSchemaName,
- },
- },
- 'x-uid': pageSchemaUid,
- name: 'page',
- },
- },
- name: uid(),
- 'x-uid': menuSchemaUid,
- },
- wrap: null,
- },
- });
-
- if (!result.ok()) {
- throw new Error(await result.text());
}
return { schemaUid, routeId };
@@ -1063,7 +1033,7 @@ const deleteMobileRoutes = async (mobileRouteId: number) => {
};
/**
- * 根据页面 uid 删除一个 NocoBase 的页面
+ * 根据页面 uid 删除一个页面的 schema,根据页面路由的 id 删除一个页面的路由
*/
const deletePage = async (pageUid: string, routeId: number) => {
const api = await request.newContext({
@@ -1083,12 +1053,14 @@ const deletePage = async (pageUid: string, routeId: number) => {
}
}
- const result = await api.post(`/api/uiSchemas:remove/${pageUid}`, {
- headers,
- });
+ if (pageUid) {
+ const result = await api.post(`/api/uiSchemas:remove/${pageUid}`, {
+ headers,
+ });
- if (!result.ok()) {
- throw new Error(await result.text());
+ if (!result.ok()) {
+ throw new Error(await result.text());
+ }
}
};
diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts b/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts
index b4fe1bf717..e204788c54 100644
--- a/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts
+++ b/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts
@@ -8,7 +8,6 @@
*/
import { expect, test } from '@nocobase/test/e2e';
-import { oneTableBlock } from './utils';
test('allows to configure interface', async ({ page, mockPage, mockRole, updateRole }) => {
await mockPage().goto();
@@ -121,13 +120,13 @@ test('new menu items allow to be asscessed by default ', async ({ page, mockPage
window.localStorage.setItem('NOCOBASE_ROLE', roleData.name);
}, roleData);
await page.reload();
- await mockPage({ ...oneTableBlock, name: 'new page' }).goto();
+ await mockPage({ name: 'new page' }).goto();
await expect(page.getByLabel('new page')).not.toBeVisible();
await updateRole({
name: roleData.name,
allowNewMenu: true,
});
- await mockPage({ ...oneTableBlock, name: 'new page' }).goto();
+ await mockPage({ name: 'new page' }).goto();
await expect(page.getByLabel('new page')).toBeVisible();
});
diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/MenuPermissions.tsx b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/MenuPermissions.tsx
index 2a1fdecd0d..2aebfaf4b6 100644
--- a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/MenuPermissions.tsx
+++ b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/MenuPermissions.tsx
@@ -9,7 +9,7 @@
import { createForm, Form, onFormValuesChange } from '@formily/core';
import { uid } from '@formily/shared';
-import { css, SchemaComponent, useAPIClient, useCompile, useRequest } from '@nocobase/client';
+import { css, SchemaComponent, useAllAccessDesktopRoutes, useAPIClient, useCompile, useRequest } from '@nocobase/client';
import { useMemoizedFn } from 'ahooks';
import { Checkbox, message, Table } from 'antd';
import { uniq } from 'lodash';
@@ -68,7 +68,7 @@ const style = css`
const translateTitle = (menus: any[], t, compile) => {
return menus.map((menu) => {
- const title = menu.title?.match(/^\s*\{\{\s*.+?\s*\}\}\s*$/) ? compile(menu.title) : t(menu.title);
+ const title = (menu.title?.match(/^\s*\{\{\s*.+?\s*\}\}\s*$/) ? compile(menu.title) : t(menu.title)) || t('Unnamed');
if (menu.children) {
return {
...menu,
@@ -123,7 +123,7 @@ const DesktopRoutesProvider: FC<{
};
export const DesktopAllRoutesProvider: React.FC<{ active: boolean }> = ({ children, active }) => {
- const refreshRef = React.useRef(() => {});
+ const refreshRef = React.useRef(() => { });
useEffect(() => {
if (active) {
@@ -166,6 +166,7 @@ export const MenuPermissions: React.FC<{
);
const resource = api.resource('roles.desktopRoutes', role.name);
const allChecked = allIDList.length === IDList.length;
+ const { refresh: refreshDesktopRoutes } = useAllAccessDesktopRoutes();
const handleChange = async (checked, menuItem) => {
// 处理取消选中
@@ -214,6 +215,7 @@ export const MenuPermissions: React.FC<{
values: shouldAdd,
});
}
+ refreshDesktopRoutes();
message.success(t('Saved successfully'));
};
@@ -288,6 +290,7 @@ export const MenuPermissions: React.FC<{
});
}
refresh();
+ refreshDesktopRoutes();
message.success(t('Saved successfully'));
}}
/>{' '}
diff --git a/packages/plugins/@nocobase/plugin-action-print/src/client/__e2e__/utils.ts b/packages/plugins/@nocobase/plugin-action-print/src/client/__e2e__/utils.ts
index 27faa3662d..0eced99eeb 100644
--- a/packages/plugins/@nocobase/plugin-action-print/src/client/__e2e__/utils.ts
+++ b/packages/plugins/@nocobase/plugin-action-print/src/client/__e2e__/utils.ts
@@ -382,8 +382,11 @@ export const oneTableWithViewAction: PageConfig = {
'x-index': 1,
},
},
+ 'x-uid': 'j0k2m5r9z3b',
+ 'x-async': false,
},
},
+ 'x-uid': 'l6ioayfnq6c',
},
};
diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx
index f012dbc4d5..653ffc6219 100644
--- a/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx
+++ b/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx
@@ -20,6 +20,7 @@ import {
handleDateChangeOnForm,
useACLRoleContext,
useActionContext,
+ useApp,
useCollection,
useCollectionParentRecordData,
useDesignable,
@@ -27,10 +28,8 @@ import {
useLazy,
usePopupUtils,
useProps,
- useToken,
withDynamicSchemaProps,
- withSkeletonComponent,
- useApp,
+ withSkeletonComponent
} from '@nocobase/client';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
@@ -76,7 +75,7 @@ const getColorString = (
};
export const DeleteEventContext = React.createContext({
- close: () => {},
+ close: () => { },
allowDeleteEvent: false,
});
diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx
index f6ed96abb9..7ad775475f 100644
--- a/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx
+++ b/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx
@@ -6,8 +6,8 @@
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
-import React from 'react';
import { Plugin, useToken } from '@nocobase/client';
+import React from 'react';
import { generateNTemplate } from '../locale';
import { CalendarV2 } from './calendar';
import { calendarBlockSettings } from './calendar/Calender.Settings';
@@ -70,9 +70,9 @@ export class PluginCalendarClient extends Plugin {
colorFieldInterfaces: {
[T: string]: { useGetColor: (field: any) => ColorFunctions };
} = {
- select: { useGetColor },
- radioGroup: { useGetColor },
- };
+ select: { useGetColor },
+ radioGroup: { useGetColor },
+ };
dateTimeFieldInterfaces = ['date', 'datetime', 'dateOnly', 'datetimeNoTz', 'unixTimestamp', 'createdAt', 'updatedAt'];
diff --git a/packages/plugins/@nocobase/plugin-client/src/client/routesTableSchema.tsx b/packages/plugins/@nocobase/plugin-client/src/client/routesTableSchema.tsx
index 6c2ed43808..c261329184 100644
--- a/packages/plugins/@nocobase/plugin-client/src/client/routesTableSchema.tsx
+++ b/packages/plugins/@nocobase/plugin-client/src/client/routesTableSchema.tsx
@@ -13,8 +13,6 @@ import { useField, useForm } from '@formily/react';
import {
CollectionField,
css,
- getGroupMenuSchema,
- getLinkMenuSchema,
getPageMenuSchema,
getTabSchema,
getVariableComponentWithScope,
@@ -26,6 +24,7 @@ import {
useCollectionRecordData,
useDataBlockRequestData,
useDataBlockRequestGetter,
+ useInsertPageSchema,
useNocoBaseRoutes,
useRequest,
useRouterBasename,
@@ -237,13 +236,13 @@ export const createRoutesTableSchema = (collectionName: string, basename: string
'x-component': 'IconPicker',
'x-reactions': isMobile
? {
- dependencies: ['type'],
- fulfill: {
- state: {
- required: '{{$deps[0] !== "tabs"}}',
- },
+ dependencies: ['type'],
+ fulfill: {
+ state: {
+ required: '{{$deps[0] !== "tabs"}}',
},
- }
+ },
+ }
: undefined,
},
// 由于历史原因,桌面端使用的是 'href' 作为 key,移动端使用的是 'url' 作为 key
@@ -575,9 +574,8 @@ export const createRoutesTableSchema = (collectionName: string, basename: string
}
if (recordData.type === NocoBaseDesktopRouteType.page) {
- const path = `${basenameOfCurrentRouter.slice(0, -1)}${basename}/${
- isMobile ? recordData.schemaUid : recordData.menuSchemaUid
- }`;
+ const path = `${basenameOfCurrentRouter.slice(0, -1)}${basename}/${isMobile ? recordData.schemaUid : recordData.menuSchemaUid
+ }`;
// 在点击 Access 按钮时,会用到
recordData._path = path;
@@ -697,13 +695,13 @@ export const createRoutesTableSchema = (collectionName: string, basename: string
'x-component': 'IconPicker',
'x-reactions': isMobile
? {
- dependencies: ['type'],
- fulfill: {
- state: {
- required: '{{$deps[0] !== "tabs"}}',
- },
+ dependencies: ['type'],
+ fulfill: {
+ state: {
+ required: '{{$deps[0] !== "tabs"}}',
},
- }
+ },
+ }
: undefined,
},
// 由于历史原因,桌面端使用的是 'href' 作为 key,移动端使用的是 'url' 作为 key
@@ -986,13 +984,13 @@ export const createRoutesTableSchema = (collectionName: string, basename: string
'x-component': 'IconPicker',
'x-reactions': isMobile
? {
- dependencies: ['type'],
- fulfill: {
- state: {
- required: '{{$deps[0] !== "tabs"}}',
- },
+ dependencies: ['type'],
+ fulfill: {
+ state: {
+ required: '{{$deps[0] !== "tabs"}}',
},
- }
+ },
+ }
: undefined,
},
// 由于历史原因,桌面端使用的是 'href' 作为 key,移动端使用的是 'url' 作为 key
@@ -1244,22 +1242,15 @@ function useCreateRouteSchema(isMobile: boolean) {
const collectionName = 'uiSchemas';
const api = useAPIClient();
const resource = useMemo(() => api.resource(collectionName), [api, collectionName]);
+ const insertPageSchema = useInsertPageSchema();
const createRouteSchema = useCallback(
async ({
- title,
- icon,
type,
- href,
- params,
}: {
- title: string;
- icon: string;
type: NocoBaseDesktopRouteType;
- href?: string;
- params?: Record;
}) => {
- const menuSchemaUid = uid();
+ const menuSchemaUid = isMobile ? undefined : uid();
const pageSchemaUid = uid();
const tabSchemaName = uid();
const tabSchemaUid = type === NocoBaseDesktopRouteType.page ? uid() : undefined;
@@ -1268,17 +1259,16 @@ function useCreateRouteSchema(isMobile: boolean) {
[NocoBaseDesktopRouteType.page]: isMobile
? getMobilePageSchema(pageSchemaUid, tabSchemaUid).schema
: getPageMenuSchema({
- title,
- icon,
- pageSchemaUid,
- tabSchemaUid,
- menuSchemaUid,
- tabSchemaName,
- }),
- [NocoBaseDesktopRouteType.group]: getGroupMenuSchema({ title, icon, schemaUid: menuSchemaUid }),
- [NocoBaseDesktopRouteType.link]: getLinkMenuSchema({ title, icon, schemaUid: menuSchemaUid, href, params }),
+ pageSchemaUid,
+ tabSchemaUid,
+ tabSchemaName,
+ }),
};
+ if (!typeToSchema[type]) {
+ return {};
+ }
+
if (isMobile) {
await resource['insertAdjacent']({
resourceIndex: 'mobile',
@@ -1288,17 +1278,12 @@ function useCreateRouteSchema(isMobile: boolean) {
},
});
} else {
- await resource['insertAdjacent/nocobase-admin-menu']({
- position: 'beforeEnd',
- values: {
- schema: typeToSchema[type],
- },
- });
+ await insertPageSchema(typeToSchema[type]);
}
return { menuSchemaUid, pageSchemaUid, tabSchemaUid, tabSchemaName };
},
- [isMobile, resource],
+ [isMobile, resource, insertPageSchema],
);
/**
diff --git a/packages/plugins/@nocobase/plugin-client/src/server/__tests__/202502071837-fix-permissions.test.ts b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/202502071837-fix-permissions.test.ts
new file mode 100644
index 0000000000..d73c50e128
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/202502071837-fix-permissions.test.ts
@@ -0,0 +1,99 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { MockServer, createMockServer } from '@nocobase/test';
+import Migration, { getIds } from '../migrations/202502071837-fix-permissions';
+import { vi, describe, beforeEach, afterEach, test, expect } from 'vitest';
+
+describe('202502071837-fix-permissions', () => {
+ let app: MockServer;
+
+ beforeEach(async () => {
+ app = await createMockServer({
+ plugins: ['nocobase'],
+ });
+ await app.version.update('1.5.0');
+ });
+
+ afterEach(async () => {
+ await app.destroy();
+ });
+
+ async function createTestData() {
+ const desktopRoutes = app.db.getRepository('desktopRoutes');
+ const roles = app.db.getRepository('roles');
+
+ // 创建测试路由
+ const routes = await desktopRoutes.create({
+ values: [
+ {
+ type: 'page',
+ title: 'Page 1',
+ menuSchemaUid: 'page1',
+ children: [{ type: 'tabs', title: 'Tabs 1', parentId: 1, schemaUid: 'tabs1' }],
+ },
+ {
+ type: 'page',
+ title: 'Page 2',
+ menuSchemaUid: 'page2',
+ },
+ ],
+ });
+
+ // 创建测试角色
+ const role = await roles.create({
+ values: {
+ name: 'test',
+ menuUiSchemas: [
+ { 'x-uid': 'page1' }, // 已有 page1 的权限
+ { 'x-uid': 'page3' }, // 不存在的权限
+ ],
+ },
+ });
+
+ return { routes, role };
+ }
+
+ test('should add missing permissions', async () => {
+ const { role } = await createTestData();
+ const migration = new Migration({ db: app.db, app } as any);
+
+ await migration.up();
+
+ // 获取更新后的角色权限
+ const updatedRole = await app.db.getRepository('roles').findOne({
+ filter: { name: 'test' },
+ appends: ['desktopRoutes'],
+ });
+
+ // 验证应该添加的权限
+ const routeIds = updatedRole.desktopRoutes.map((r) => r.id);
+ expect(routeIds).toContain(1); // page1 已存在
+ expect(routeIds).toContain(2); // tabs1 应该被添加
+ expect(routeIds).not.toContain(3); // page2 不应该被添加
+ });
+
+ test('should handle empty desktop routes', async () => {
+ const migration = new Migration({ db: app.db, app } as any);
+ await expect(migration.up()).resolves.not.toThrow();
+ });
+
+ test('getIds should return correct needAddIds', () => {
+ const desktopRoutes = [
+ { id: 1, type: 'page', menuSchemaUid: 'page1' },
+ { id: 2, type: 'tabs', parentId: 1, schemaUid: 'tabs1' },
+ { id: 3, type: 'page', menuSchemaUid: 'page2' },
+ ];
+
+ const menuUiSchemas = [{ 'x-uid': 'page1' }];
+
+ const { needAddIds } = getIds(desktopRoutes, menuUiSchemas);
+ expect(needAddIds).toEqual([1, 2]); // page1 已存在但 tabs1/page2 需要添加
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-client/src/server/__tests__/desktopRoutes.test.ts b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/desktopRoutes.test.ts
new file mode 100644
index 0000000000..7716fa97ab
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-client/src/server/__tests__/desktopRoutes.test.ts
@@ -0,0 +1,170 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import Database from '@nocobase/database';
+import { createMockServer, MockServer } from '@nocobase/test';
+
+describe('desktopRoutes:listAccessible', () => {
+ let app: MockServer;
+ let db: Database;
+
+ beforeEach(async () => {
+ app = await createMockServer({
+ registerActions: true,
+ acl: true,
+ plugins: ['nocobase'],
+ });
+ db = app.db;
+
+ // 创建测试页面和tab路由
+ await db.getRepository('desktopRoutes').create({
+ values: [
+ {
+ type: 'page',
+ title: 'page1',
+ children: [{ type: 'tab', title: 'tab1' }],
+ },
+ {
+ type: 'page',
+ title: 'page2',
+ children: [{ type: 'tab', title: 'tab2' }],
+ },
+ {
+ type: 'page',
+ title: 'page3',
+ children: [{ type: 'tab', title: 'tab3' }],
+ },
+ ],
+ });
+ });
+
+ afterEach(async () => {
+ await app.destroy();
+ });
+
+ it('should return all routes for root role', async () => {
+ const rootUser = await db.getRepository('users').create({
+ values: { roles: ['root'] },
+ });
+ const agent = await app.agent().login(rootUser);
+
+ const response = await agent.resource('desktopRoutes').listAccessible();
+ expect(response.status).toBe(200);
+ expect(response.body.data.length).toBe(3);
+ expect(response.body.data[0].children.length).toBe(1);
+ });
+
+ it('should return all routes by default for admin/member', async () => {
+ // 测试 admin 角色
+ const adminUser = await db.getRepository('users').create({
+ values: { roles: ['admin'] },
+ });
+ const adminAgent = await app.agent().login(adminUser);
+
+ let response = await adminAgent.resource('desktopRoutes').listAccessible();
+ expect(response.body.data.length).toBe(3);
+
+ // 测试 member 角色
+ const memberUser = await db.getRepository('users').create({
+ values: { roles: ['member'] },
+ });
+ const memberAgent = await app.agent().login(memberUser);
+
+ response = await memberAgent.resource('desktopRoutes').listAccessible();
+ expect(response.body.data.length).toBe(3);
+ });
+
+ it('should return filtered routes with children', async () => {
+ // 使用 root 角色配置 member 的可访问路由
+ const rootUser = await db.getRepository('users').create({
+ values: { roles: ['root'] },
+ });
+ const rootAgent = await app.agent().login(rootUser);
+
+ // 更新 member 角色的可访问路由
+ await rootAgent.resource('roles.desktopRoutes', 'member').remove({
+ values: [1, 2, 3, 4, 5, 6], // 移除所有路由的访问权限
+ });
+ await rootAgent.resource('roles.desktopRoutes', 'member').add({
+ values: [1, 2], // 再加上 page1 和 tab1 的访问权限
+ });
+
+ // 使用 member 用户测试
+ const memberUser = await db.getRepository('users').create({
+ values: { roles: ['member'] },
+ });
+ const memberAgent = await app.agent().login(memberUser);
+
+ const response = await memberAgent.resource('desktopRoutes').listAccessible();
+ expect(response.body.data.length).toBe(1);
+ expect(response.body.data[0].title).toBe('page1');
+ expect(response.body.data[0].children.length).toBe(1);
+ expect(response.body.data[0].children[0].title).toBe('tab1');
+ });
+
+ it('should return an empty response when there are no accessible routes', async () => {
+ // 使用 root 角色配置 member 的可访问路由
+ const rootUser = await db.getRepository('users').create({
+ values: { roles: ['root'] },
+ });
+ const rootAgent = await app.agent().login(rootUser);
+
+ // 更新 member 角色的可访问路由
+ await rootAgent.resource('roles.desktopRoutes', 'member').remove({
+ values: [1, 2, 3, 4, 5, 6], // 移除所有路由的访问权限
+ });
+
+ // 使用 member 用户测试
+ const memberUser = await db.getRepository('users').create({
+ values: { roles: ['member'] },
+ });
+ const memberAgent = await app.agent().login(memberUser);
+
+ const response = await memberAgent.resource('desktopRoutes').listAccessible();
+ expect(response.body.data.length).toBe(0);
+ });
+
+ it('should auto include children when page has no children', async () => {
+ // 创建一个没有子路由的页面
+ const page4 = await db.getRepository('desktopRoutes').create({
+ values: {
+ type: 'page',
+ title: 'page4',
+ },
+ });
+
+ // 创建两个子路由
+ await db.getRepository('desktopRoutes').create({
+ values: [
+ { type: 'tab', title: 'tab4-1', parentId: page4.id },
+ { type: 'tab', title: 'tab4-2', parentId: page4.id },
+ ],
+ });
+
+ // 配置 member 角色只能访问 page4
+ const rootUser = await db.getRepository('users').create({
+ values: { roles: ['root'] },
+ });
+ const rootAgent = await app.agent().login(rootUser);
+ await rootAgent.resource('roles.desktopRoutes', 'member').remove({
+ values: [1, 2, 3, 4, 5, 6, 8, 9], // 只保留 page4 的访问权限
+ });
+
+ // 验证返回结果包含子路由
+ const memberUser = await db.getRepository('users').create({
+ values: { roles: ['member'] },
+ });
+ const memberAgent = await app.agent().login(memberUser);
+
+ const response = await memberAgent.resource('desktopRoutes').listAccessible();
+ expect(response.body.data.length).toBe(1);
+ expect(response.body.data[0].title).toBe('page4');
+ expect(response.body.data[0].children.length).toBe(2);
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-client/src/server/server.ts b/packages/plugins/@nocobase/plugin-client/src/server/server.ts
index c114001b5a..8b28b97d95 100644
--- a/packages/plugins/@nocobase/plugin-client/src/server/server.ts
+++ b/packages/plugins/@nocobase/plugin-client/src/server/server.ts
@@ -8,14 +8,14 @@
*/
import { Model } from '@nocobase/database';
+import PluginLocalizationServer from '@nocobase/plugin-localization';
import { Plugin } from '@nocobase/server';
+import { tval } from '@nocobase/utils';
import * as process from 'node:process';
import { resolve } from 'path';
import { getAntdLocale } from './antd';
import { getCronLocale } from './cron';
import { getCronstrueLocale } from './cronstrue';
-import PluginLocalizationServer from '@nocobase/plugin-localization';
-import { tval } from '@nocobase/utils';
async function getLang(ctx) {
const SystemSetting = ctx.db.getRepository('systemSettings');
@@ -216,20 +216,35 @@ export class PluginClientServer extends Plugin {
appends: ['desktopRoutes'],
});
- const desktopRoutesId = role
- .get('desktopRoutes')
- // hidden 为 true 的节点不会显示在权限配置表格中,所以无法被配置,需要被过滤掉
- .filter((item) => !item.hidden)
- .map((item) => item.id);
+ // 1. 如果 page 的 children 为空,那么需要把 page 的 children 全部找出来,然后返回。否则前端会因为缺少 tab 路由的数据而导致页面空白
+ // 2. 如果 page 的 children 不为空,不需要做特殊处理
+ const desktopRoutesId = role.get('desktopRoutes').map(async (item, index, items) => {
+ if (item.type === 'page' && !items.some((tab) => tab.parentId === item.id)) {
+ const children = await desktopRoutesRepository.find({
+ filter: {
+ parentId: item.id,
+ },
+ });
- ctx.body = await desktopRoutesRepository.find({
- tree: true,
- ...ctx.query,
- filter: {
- id: desktopRoutesId,
- },
+ return [item.id, ...(children || []).map((child) => child.id)];
+ }
+
+ return item.id;
});
+ if (desktopRoutesId) {
+ const ids = (await Promise.all(desktopRoutesId)).flat();
+ const result = await desktopRoutesRepository.find({
+ tree: true,
+ ...ctx.query,
+ filter: {
+ id: ids,
+ },
+ });
+
+ ctx.body = result;
+ }
+
await next();
});
}
diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts
index a506df1fad..06751439b7 100644
--- a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts
+++ b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts
@@ -29,25 +29,15 @@ export const useStyles = genStyleHook('nb-mobile-navigation-bar-action', (token)
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
-
- '.schema-toolbar': {
- inset: '-15px -8px',
- },
},
'.nb-navigation-bar-action-title': {
fontSize: 17,
padding: 0,
- '.schema-toolbar': {
- inset: '-15px -8px',
- },
},
'.nb-navigation-bar-action-icon-and-title': {
height: '32px !important',
fontSize: '17px !important',
padding: '0 6px !important',
- '.schema-toolbar': {
- inset: '-15px',
- },
},
},
};
diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/tabs/settings.tsx b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/tabs/settings.tsx
index 9f6fa411f6..299fcf3aeb 100644
--- a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/tabs/settings.tsx
+++ b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/tabs/settings.tsx
@@ -7,20 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
-import React, { FC } from 'react';
-import { App } from 'antd';
-import { useNavigate } from 'react-router-dom';
import {
- SchemaSettings,
- SchemaToolbar,
- useSchemaToolbar,
- SchemaToolbarProvider,
createTextSettingsItem,
+ SchemaSettings,
SchemaSettingsItemType,
+ SchemaToolbar,
+ SchemaToolbarProvider,
+ useSchemaToolbar,
} from '@nocobase/client';
+import { App } from 'antd';
+import React, { FC } from 'react';
+import { useNavigate } from 'react-router-dom';
-import { MobileRouteItem, useMobileRoutes } from '../../../../mobile-providers';
import { generatePluginTranslationTemplate, usePluginTranslation } from '../../../../locale';
+import { MobileRouteItem, useMobileRoutes } from '../../../../mobile-providers';
const remove = createTextSettingsItem({
name: 'remove',
@@ -118,7 +118,7 @@ export const MobilePageTabsSettings: FC = ({ tab })
settings={mobilePageTabsSettings}
showBackground
showBorder={false}
- toolbarStyle={{ inset: '-15px -12px' }}
+ toolbarStyle={{ inset: '0 -12px' }}
spaceWrapperStyle={{ top: 3 }}
/>
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/index.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/index.tsx
index b51e3afab7..2fb72f3640 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client/index.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client/index.tsx
@@ -18,22 +18,22 @@ const { ExecutionPage } = lazy(() => import('./ExecutionPage'), 'ExecutionPage')
const { WorkflowPage } = lazy(() => import('./WorkflowPage'), 'WorkflowPage');
const { WorkflowPane } = lazy(() => import('./WorkflowPane'), 'WorkflowPane');
-import { Trigger } from './triggers';
-import CollectionTrigger from './triggers/collection';
-import ScheduleTrigger from './triggers/schedule';
+import { NAMESPACE } from './locale';
import { Instruction } from './nodes';
import CalculationInstruction from './nodes/calculation';
import ConditionInstruction from './nodes/condition';
+import CreateInstruction from './nodes/create';
+import DestroyInstruction from './nodes/destroy';
import EndInstruction from './nodes/end';
import QueryInstruction from './nodes/query';
-import CreateInstruction from './nodes/create';
import UpdateInstruction from './nodes/update';
-import DestroyInstruction from './nodes/destroy';
-import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './utils';
-import { lang, NAMESPACE } from './locale';
-import { VariableOption } from './variable';
-import { WorkflowTasks, TasksProvider, TaskTypeOptions } from './WorkflowTasks';
import { BindWorkflowConfig } from './settings/BindWorkflowConfig';
+import { Trigger } from './triggers';
+import CollectionTrigger from './triggers/collection';
+import ScheduleTrigger from './triggers/schedule';
+import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './utils';
+import { VariableOption } from './variable';
+import { TasksProvider, TaskTypeOptions, WorkflowTasks } from './WorkflowTasks';
const workflowConfigSettings = {
Component: BindWorkflowConfig,
@@ -76,7 +76,7 @@ export default class PluginWorkflowClient extends Plugin {
return this.triggers.get(workflow.type)?.sync ?? workflow.sync;
}
- registerTrigger(type: string, trigger: Trigger | { new (): Trigger }) {
+ registerTrigger(type: string, trigger: Trigger | { new(): Trigger }) {
if (typeof trigger === 'function') {
this.triggers.register(type, new trigger());
} else if (trigger) {
@@ -86,7 +86,7 @@ export default class PluginWorkflowClient extends Plugin {
}
}
- registerInstruction(type: string, instruction: Instruction | { new (): Instruction }) {
+ registerInstruction(type: string, instruction: Instruction | { new(): Instruction }) {
if (typeof instruction === 'function') {
this.instructions.register(type, new instruction());
} else if (instruction instanceof Instruction) {
@@ -182,15 +182,15 @@ export default class PluginWorkflowClient extends Plugin {
}
export * from './Branch';
-export * from './FlowContext';
-export * from './constants';
-export * from './nodes';
-export { Trigger, useTrigger } from './triggers';
-export * from './variable';
export * from './components';
-export * from './utils';
-export * from './hooks';
-export { default as useStyles } from './style';
-export * from './variable';
+export * from './constants';
export * from './ExecutionContextProvider';
+export * from './FlowContext';
+export * from './hooks';
+export * from './nodes';
export * from './settings/BindWorkflowConfig';
+export { default as useStyles } from './style';
+export { Trigger, useTrigger } from './triggers';
+export * from './utils';
+export * from './variable';
+