Merge branch 'develop' of github.com:nocobase/nocobase into feat-main-datasource-mssql

This commit is contained in:
aaaaaajie 2025-03-30 19:22:19 +08:00
commit c98508d12c
37 changed files with 1305 additions and 321 deletions

View File

@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v1.6.14](https://github.com/nocobase/nocobase/compare/v1.6.13...v1.6.14) - 2025-03-29
### 🐛 Bug Fixes
- **[Calendar]** missing data on boundary dates in weekly calendar view ([#6587](https://github.com/nocobase/nocobase/pull/6587)) by @katherinehhh
- **[Auth: OIDC]** Incorrect redirection occurs when the callback path is the string 'null' by @2013xile
- **[Workflow: Approval]** Fix approval node configuration is incorrect after schema changed by @mytharcher
## [v1.6.13](https://github.com/nocobase/nocobase/compare/v1.6.12...v1.6.13) - 2025-03-28
### 🚀 Improvements
- **[Async task manager]** optimize import/export buttons in Pro ([#6531](https://github.com/nocobase/nocobase/pull/6531)) by @chenos
- **[Action: Export records Pro]** optimize import/export buttons in Pro by @katherinehhh
- **[Migration manager]** allow skip automatic backup and restore for migration by @gchust
### 🐛 Bug Fixes
- **[client]** linkage conflict between same-named association fields in different sub-tables within the same form ([#6577](https://github.com/nocobase/nocobase/pull/6577)) by @katherinehhh
- **[Action: Batch edit]** Click the batch edit button, configure the pop-up window, and then open it again, the pop-up window is blank ([#6578](https://github.com/nocobase/nocobase/pull/6578)) by @zhangzhonghe
## [v1.6.12](https://github.com/nocobase/nocobase/compare/v1.6.11...v1.6.12) - 2025-03-27
### 🐛 Bug Fixes

View File

@ -5,6 +5,32 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。
## [v1.6.14](https://github.com/nocobase/nocobase/compare/v1.6.13...v1.6.14) - 2025-03-29
### 🐛 修复
- **[日历]** 日历区块以周为视图时,边界日期不显示数据 ([#6587](https://github.com/nocobase/nocobase/pull/6587)) by @katherinehhh
- **[认证OIDC]** 回调路径是字符串'null'时导致跳转不正确 by @2013xile
- **[工作流:审批]** 修复审批节点界面配置变更后数据未同步的问题 by @mytharcher
## [v1.6.13](https://github.com/nocobase/nocobase/compare/v1.6.12...v1.6.13) - 2025-03-28
### 🚀 优化
- **[异步任务管理器]** 优化 Pro 导入导出按钮异步逻辑 ([#6531](https://github.com/nocobase/nocobase/pull/6531)) by @chenos
- **[操作:导出记录 Pro]** 优化 Pro 导入导出按钮 by @katherinehhh
- **[迁移管理]** 允许执行迁移时跳过自动备份还原 by @gchust
### 🐛 修复
- **[client]** 同一表单中不同关系字段的同名关系字段的联动互相影响 ([#6577](https://github.com/nocobase/nocobase/pull/6577)) by @katherinehhh
- **[操作:批量编辑]** 点击批量编辑按钮,配置完弹窗再打开,弹窗是空白的 ([#6578](https://github.com/nocobase/nocobase/pull/6578)) by @zhangzhonghe
## [v1.6.12](https://github.com/nocobase/nocobase/compare/v1.6.11...v1.6.12) - 2025-03-27
### 🐛 修复

View File

@ -18,7 +18,14 @@ export interface SelectWithTitleProps {
onChange?: (...args: any[]) => void;
}
export function SelectWithTitle({ title, defaultValue, onChange, options, fieldNames }: SelectWithTitleProps) {
export function SelectWithTitle({
title,
defaultValue,
onChange,
options,
fieldNames,
...others
}: SelectWithTitleProps) {
const [open, setOpen] = useState(false);
const timerRef = useRef<any>(null);
return (
@ -36,6 +43,7 @@ export function SelectWithTitle({ title, defaultValue, onChange, options, fieldN
>
{title}
<Select
{...others}
open={open}
data-testid={`select-${title}`}
popupMatchSelectWidth={false}

View File

@ -20,7 +20,6 @@ import { FilterBlockProvider } from '../../../filter-provider/FilterProvider';
import {
NocoBaseRecursionField,
RefreshComponentProvider,
useRefreshComponent,
useRefreshFieldSchema,
} from '../../../formily/NocoBaseRecursionField';
import { DndContext, DndContextProps } from '../../common/dnd-context';
@ -379,11 +378,9 @@ export const Grid: any = observer(
}, [fieldSchema, render, InitializerComponent, showDivider]);
const refreshFieldSchema = useRefreshFieldSchema();
const refreshComponent = useRefreshComponent();
const refresh = useCallback(() => {
refreshFieldSchema?.();
refreshComponent?.();
}, [refreshComponent, refreshFieldSchema]);
}, [refreshFieldSchema]);
return (
<RefreshComponentProvider refresh={refresh}>

View File

@ -568,13 +568,14 @@ export interface SchemaSettingsSelectItemProps
extends Omit<SchemaSettingsItemProps, 'onChange' | 'onClick'>,
Omit<SelectWithTitleProps, 'title' | 'defaultValue'> {
value?: SelectWithTitleProps['defaultValue'];
optionRender?: (option: any, info: { index: number }) => React.ReactNode;
}
export const SchemaSettingsSelectItem: FC<SchemaSettingsSelectItemProps> = (props) => {
const { title, options, value, onChange, ...others } = props;
const { title, options, value, onChange, optionRender, ...others } = props;
return (
<SchemaSettingsItem title={title} {...others}>
<SelectWithTitle {...{ title, defaultValue: value, onChange, options }} />
<SelectWithTitle {...{ title, defaultValue: value, onChange, options, optionRender }} />
</SchemaSettingsItem>
);
};

View File

@ -51,7 +51,11 @@ describe('union role: full permissions', async () => {
roles: [role1.name, role2.name],
},
});
await rootAgent.resource('roles').setSystemRoleMode({
values: {
roleMode: SystemRoleMode.allowUseUnion,
},
});
agent = await app.agent().login(user, UNION_ROLE_KEY);
});
@ -415,15 +419,15 @@ describe('union role: full permissions', async () => {
const rootAgent = await app.agent().login(rootUser);
let rolesResponse = await agent.resource('roles').check();
expect(rolesResponse.status).toBe(200);
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.default);
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.allowUseUnion);
await rootAgent.resource('roles').setSystemRoleMode({
values: {
roleMode: SystemRoleMode.allowUseUnion,
roleMode: SystemRoleMode.default,
},
});
rolesResponse = await agent.resource('roles').check();
expect(rolesResponse.status).toBe(200);
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.allowUseUnion);
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.default);
});
it(`should response no permission when createdById field is missing in data tables`, async () => {
@ -501,4 +505,29 @@ describe('union role: full permissions', async () => {
expect(getRolesResponse.statusCode).toBe(200);
expect(getRolesResponse.body.meta.allowedActions.update.length).toBe(0);
});
it('should login successfully when use __union__ role in allowUseUnion mode #1906', async () => {
const rootAgent = await app.agent().login(rootUser);
await rootAgent.resource('roles').setSystemRoleMode({
values: {
roleMode: SystemRoleMode.allowUseUnion,
},
});
agent = await app.agent().login(user);
const createRoleResponse = await agent.resource('roles').check();
expect(createRoleResponse.statusCode).toBe(200);
});
it('should currentRole not be __union__ when default role mode #1907', async () => {
const rootAgent = await app.agent().login(rootUser);
await rootAgent.resource('roles').setSystemRoleMode({
values: {
roleMode: SystemRoleMode.default,
},
});
agent = await app.agent().login(user, UNION_ROLE_KEY);
const createRoleResponse = await agent.resource('roles').check();
expect(createRoleResponse.statusCode).toBe(200);
expect(createRoleResponse.body.data.role).not.toBe(UNION_ROLE_KEY);
});
});

View File

@ -14,7 +14,7 @@ import { UNION_ROLE_KEY } from '../constants';
import { SystemRoleMode } from '../enum';
export async function setCurrentRole(ctx: Context, next) {
const currentRole = ctx.get('X-Role');
let currentRole = ctx.get('X-Role');
if (currentRole === 'anonymous') {
ctx.state.currentRole = currentRole;
@ -49,7 +49,8 @@ export async function setCurrentRole(ctx: Context, next) {
ctx.state.currentUser.roles = userRoles;
const systemSettings = await ctx.db.getRepository('systemSettings').findOne();
const roleMode = systemSettings?.get('roleMode') || SystemRoleMode.default;
if (ctx.state.currentRole === UNION_ROLE_KEY && roleMode === SystemRoleMode.default) {
if ([currentRole, ctx.state.currentRole].includes(UNION_ROLE_KEY) && roleMode === SystemRoleMode.default) {
currentRole = userRoles[0].name;
ctx.state.currentRole = userRoles[0].name;
ctx.headers['x-role'] = userRoles[0].name;
} else if (roleMode === SystemRoleMode.onlyUseUnion) {
@ -85,7 +86,7 @@ export async function setCurrentRole(ctx: Context, next) {
// 2. If the X-Role is not set, or the X-Role does not belong to the user, use the default role
if (!role) {
const defaultRole = userRoles.find((role) => role?.rolesUsers?.default);
role = (defaultRole || userRoles[0])?.name;
role = (defaultRole || userRoles.find((x) => x.name !== UNION_ROLE_KEY))?.name;
}
ctx.state.currentRole = role;
ctx.state.currentRoles = [role];

View File

@ -0,0 +1,34 @@
/**
* 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 { expect, test } from '@nocobase/test/e2e';
import { afterConfiguringTheModalWhenReopeningItTheContentShouldPersist } from './utils';
test.describe('refresh', () => {
test('After configuring the modal, when reopening it, the content should persist', async ({ mockPage, page }) => {
await mockPage(afterConfiguringTheModalWhenReopeningItTheContentShouldPersist).goto();
// 1. 点击 Bulk edit 按钮,打开弹窗
await page.getByLabel('action-Action-Bulk edit-').click();
// 2. 新增一个表单区块
await page.getByLabel('schema-initializer-Grid-popup').hover();
await page.getByRole('menuitem', { name: 'form Form' }).click();
// 3. 新增一个名为 Nickname 的字段
await page.getByLabel('schema-initializer-Grid-bulkEditForm:configureFields-users').hover();
await page.getByRole('menuitem', { name: 'Nickname' }).click();
// 4. 关闭弹窗,然后再打开,刚才新增的字段应该还在
await page.getByLabel('drawer-Action.Container-users-Bulk edit-mask').click();
await page.getByLabel('action-Action-Bulk edit-').click();
await expect(page.getByLabel('block-item-BulkEditField-').getByText('Nickname')).toBeVisible();
await page.getByLabel('block-item-BulkEditField-').click();
});
});

View File

@ -1243,3 +1243,388 @@ export const theAddBlockButtonInDrawerShouldBeVisible = {
'x-index': 1,
},
};
export const afterConfiguringTheModalWhenReopeningItTheContentShouldPersist = {
pageSchema: {
type: 'void',
'x-component': 'Page',
name: 'rjzvy4bmawn',
'x-uid': '1rs9caegbf2',
'x-async': false,
properties: {
tab: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
properties: {
bmsmf8futai: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'x84k7qs6jko',
'x-async': false,
'x-index': 4,
},
noe2oca30hc: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 't7jxa830ps6',
'x-async': false,
'x-index': 5,
},
w2hnq7rau9p: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': '0fjjtg8z7ws',
'x-async': false,
'x-index': 7,
},
fcfs4oot86g: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'nklv7lonpgn',
'x-async': false,
'x-index': 8,
},
i22fydav355: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'fz4g6cr9jvr',
'x-async': false,
'x-index': 10,
},
row_6u7y7uccrvz: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-index': 12,
'x-uid': '7tzumo4nec7',
'x-async': false,
},
higfesvgj7g: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': '8hpa6qf3sez',
'x-async': false,
'x-index': 13,
},
'37myao9n0wc': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'uw1dp2qxd3y',
'x-async': false,
'x-index': 14,
},
uvfd76q4ye9: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'nc56fu33m42',
'x-async': false,
'x-index': 15,
},
miidizeqgot: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'jf4qarrcs0z',
'x-async': false,
'x-index': 16,
},
hxmr87i5imu: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'l3kdiqd9a7k',
'x-async': false,
'x-index': 17,
},
pa8dwdi4h5a: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'uz5wcet83qn',
'x-async': false,
'x-index': 18,
},
pno0a05tbnp: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'b4bakhhasp3',
'x-async': false,
'x-index': 19,
},
uj09g5xgnr1: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'qks035fnfl6',
'x-async': false,
'x-index': 20,
},
giobcwj316k: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
'x-uid': 'awwsb89nyso',
'x-async': false,
'x-index': 22,
},
oznewtbvuyw: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.11',
properties: {
bwtax0bnnp3: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.6.11',
properties: {
c0bypj7wg5q: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': 'users:list',
'x-use-decorator-props': 'useTableBlockDecoratorProps',
'x-decorator-props': {
collection: 'users',
dataSource: 'main',
action: 'list',
params: {
pageSize: 20,
},
rowKey: 'id',
showIndex: true,
dragSort: false,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': [],
'x-app-version': '1.6.11',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'table:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
'x-app-version': '1.6.11',
properties: {
'1dlvhzr308c': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{t("Bulk edit")}}',
'x-component': 'Action',
'x-action': 'customize:bulkEdit',
'x-action-settings': {
updateMode: 'selected',
},
'x-component-props': {
openMode: 'drawer',
icon: 'EditOutlined',
},
'x-align': 'right',
'x-decorator': 'BulkEditActionDecorator',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:bulkEdit',
'x-acl-action': 'update',
'x-acl-action-props': {
skipScopeCheck: true,
},
'x-app-version': '1.6.11',
properties: {
drawer: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{t("Bulk edit")}}',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
'x-app-version': '1.6.11',
properties: {
tabs: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'popup:addTab',
'x-initializer-props': {
gridInitializer: 'popup:bulkEdit:addBlock',
},
'x-app-version': '1.6.11',
properties: {
tab1: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{t("Bulk edit")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
'x-app-version': '1.6.11',
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'popup:bulkEdit:addBlock',
'x-app-version': '1.6.11',
'x-uid': '5ejbu8v5ol8',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'gfheiqtl7f7',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'if2rcx1dy2n',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'cxyi8q6lm3n',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'vbvf13xq15t',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'amlzm32jhwg',
'x-async': false,
'x-index': 1,
},
f232o2ds23n: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'array',
'x-initializer': 'table:configureColumns',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
'x-app-version': '1.6.11',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-initializer': 'table:configureItemActions',
'x-settings': 'fieldSettings:TableColumn',
'x-toolbar-props': {
initializer: 'table:configureItemActions',
},
'x-app-version': '1.6.11',
properties: {
'153lpq30p5f': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
'x-app-version': '1.6.11',
'x-uid': '4mha1dmmyz9',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'afmceivuaf0',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'iu0xkmeuc5z',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'ylor106s9ok',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'rl50hidu14n',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'xxhug2yumqf',
'x-async': false,
'x-index': 23,
},
},
name: 'h63eibc46on',
'x-uid': 'u9g23o0ohgk',
'x-async': true,
'x-index': 1,
},
},
},
};

View File

@ -1,176 +1,24 @@
import { PinnedPluginListProvider, SchemaComponentOptions, useApp } from '@nocobase/client';
/**
* 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 { PinnedPluginListProvider, SchemaComponentOptions, useRequest } from '@nocobase/client';
import React from 'react';
import { AsyncTasks } from './components/AsyncTasks';
import React, { useEffect, useState, createContext, useContext, useCallback } from 'react';
import { message } from 'antd';
import { useT } from './locale';
export const AsyncTaskContext = createContext<any>(null);
export const useAsyncTask = () => {
const context = useContext(AsyncTaskContext);
if (!context) {
throw new Error('useAsyncTask must be used within AsyncTaskManagerProvider');
}
return context;
};
export const AsyncTaskManagerProvider = (props) => {
const app = useApp();
const t = useT();
const [tasks, setTasks] = useState<any[]>([]);
const [popoverVisible, setPopoverVisible] = useState(false);
const [hasProcessingTasks, setHasProcessingTasks] = useState(false);
const [cancellingTasks, setCancellingTasks] = useState<Set<string>>(new Set());
const [modalVisible, setModalVisible] = useState(false);
const [currentError, setCurrentError] = useState<any>(null);
const [resultModalVisible, setResultModalVisible] = useState(false);
const [currentTask, setCurrentTask] = useState(null);
const [wsAuthorized, setWsAuthorized] = useState(() => app.isWsAuthorized);
useEffect(() => {
setHasProcessingTasks(tasks.some((task) => task.status.type !== 'success' && task.status.type !== 'failed'));
}, [tasks]);
const handleTaskMessage = useCallback((event: CustomEvent) => {
const tasks = event.detail;
setTasks(tasks ? tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) : []);
}, []);
const handleTaskCreated = useCallback((event: CustomEvent) => {
const taskData = event.detail;
setTasks((prev) => {
const newTasks = [taskData, ...prev];
return newTasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
});
setPopoverVisible(true);
}, []);
const handleTaskProgress = useCallback((event: CustomEvent) => {
const { taskId, progress } = event.detail;
setTasks((prev) => prev.map((task) => (task.taskId === taskId ? { ...task, progress } : task)));
}, []);
const handleTaskStatus = useCallback((event: CustomEvent) => {
const { taskId, status } = event.detail;
if (status.type === 'cancelled') {
setTasks((prev) => prev.filter((task) => task.taskId !== taskId));
} else {
setTasks((prev) => {
const newTasks = prev.map((task) => {
if (task.taskId === taskId) {
if (status.type === 'success' && task.status.type !== 'success') {
message.success(t('Task completed'));
}
if (status.type === 'failed' && task.status.type !== 'failed') {
message.error(t('Task failed'));
}
return { ...task, status };
}
return task;
});
return newTasks;
});
}
}, []);
const handleWsAuthorized = useCallback(() => {
setWsAuthorized(true);
}, []);
const handleTaskCancelled = useCallback((event: CustomEvent) => {
const { taskId } = event.detail;
setCancellingTasks((prev) => {
const newSet = new Set(prev);
newSet.delete(taskId);
return newSet;
});
message.success(t('Task cancelled'));
}, []);
useEffect(() => {
app.eventBus.addEventListener('ws:message:async-tasks', handleTaskMessage);
app.eventBus.addEventListener('ws:message:async-tasks:created', handleTaskCreated);
app.eventBus.addEventListener('ws:message:async-tasks:progress', handleTaskProgress);
app.eventBus.addEventListener('ws:message:async-tasks:status', handleTaskStatus);
app.eventBus.addEventListener('ws:message:authorized', handleWsAuthorized);
app.eventBus.addEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
if (wsAuthorized) {
app.ws.send(
JSON.stringify({
type: 'request:async-tasks:list',
}),
);
}
return () => {
app.eventBus.removeEventListener('ws:message:async-tasks', handleTaskMessage);
app.eventBus.removeEventListener('ws:message:async-tasks:created', handleTaskCreated);
app.eventBus.removeEventListener('ws:message:async-tasks:progress', handleTaskProgress);
app.eventBus.removeEventListener('ws:message:async-tasks:status', handleTaskStatus);
app.eventBus.removeEventListener('ws:message:authorized', handleWsAuthorized);
app.eventBus.removeEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
};
}, [
app,
handleTaskMessage,
handleTaskCreated,
handleTaskProgress,
handleTaskStatus,
handleWsAuthorized,
handleTaskCancelled,
wsAuthorized,
]);
const handleCancelTask = async (taskId: string) => {
setCancellingTasks((prev) => new Set(prev).add(taskId));
try {
app.ws.send(
JSON.stringify({
type: 'request:async-tasks:cancel',
payload: { taskId },
}),
);
} catch (error) {
console.error('Failed to cancel task:', error);
setCancellingTasks((prev) => {
const newSet = new Set(prev);
newSet.delete(taskId);
return newSet;
});
}
};
const contextValue = {
tasks,
popoverVisible,
setPopoverVisible,
hasProcessingTasks,
cancellingTasks,
modalVisible,
setModalVisible,
currentError,
setCurrentError,
resultModalVisible,
setResultModalVisible,
currentTask,
setCurrentTask,
handleCancelTask,
};
return (
<AsyncTaskContext.Provider value={contextValue}>
<PinnedPluginListProvider
items={
tasks.length > 0
? {
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
}
: {}
}
>
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
</PinnedPluginListProvider>
</AsyncTaskContext.Provider>
<PinnedPluginListProvider
items={{
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
}}
>
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
</PinnedPluginListProvider>
);
};

View File

@ -1,13 +1,31 @@
import React, { useEffect } from 'react';
import { Button, Popover, Table, Tag, Progress, Space, Tooltip, Popconfirm, Modal, Empty } from 'antd';
import { createStyles, Icon, useApp, usePlugin } from '@nocobase/client';
/**
* 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 {
createStyles,
Icon,
useAPIClient,
useApp,
usePlugin,
useRequest,
useCollectionManager,
useCompile,
} from '@nocobase/client';
import { Button, Empty, Modal, Popconfirm, Popover, Progress, Space, Table, Tag, Tooltip } from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { useCurrentAppInfo } from '@nocobase/client';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useT } from '../locale';
import { useAsyncTask } from '../AsyncTaskManagerProvider';
import { useCurrentAppInfo } from '@nocobase/client';
const useStyles = createStyles(({ token }) => {
return {
button: {
@ -34,93 +52,27 @@ const renderTaskResult = (status, t) => {
);
};
export const AsyncTasks = () => {
const {
tasks,
popoverVisible,
setPopoverVisible,
hasProcessingTasks,
cancellingTasks,
modalVisible,
setModalVisible,
currentError,
setCurrentError,
resultModalVisible,
setResultModalVisible,
currentTask,
setCurrentTask,
handleCancelTask,
} = useAsyncTask();
const useAsyncTask = () => {
const { data, refreshAsync, loading } = useRequest<any>({
url: 'asyncTasks:list',
});
return { loading, tasks: data?.data || [], refresh: refreshAsync };
};
const plugin = usePlugin<any>('async-task-manager');
const AsyncTasksButton = (props) => {
const { popoverVisible, setPopoverVisible, tasks, refresh, loading, hasProcessingTasks } = props;
const app = useApp();
const api = useAPIClient();
const appInfo = useCurrentAppInfo();
const t = useT();
const { styles } = useStyles();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverVisible) {
const popoverElements = document.querySelectorAll('.ant-popover');
const buttonElement = document.querySelector('.sync-task-button');
let clickedInside = false;
popoverElements.forEach((element) => {
if (element.contains(event.target as Node)) {
clickedInside = true;
}
});
if (buttonElement?.contains(event.target as Node)) {
clickedInside = true;
}
if (!clickedInside) {
setPopoverVisible(false);
}
}
};
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [popoverVisible, setPopoverVisible]);
const plugin = usePlugin<any>('async-task-manager');
const cm = useCollectionManager();
const compile = useCompile();
const showTaskResult = (task) => {
setCurrentTask(task);
setResultModalVisible(true);
setPopoverVisible(false);
};
const renderTaskResultModal = () => {
if (!currentTask) {
return;
}
const { payload } = currentTask.status;
const renderer = plugin.taskResultRendererManager.get(currentTask.title.actionType);
return (
<Modal
title={t('Task result')}
open={resultModalVisible}
footer={[
<Button key="close" onClick={() => setResultModalVisible(false)}>
{t('Close')}
</Button>,
]}
onCancel={() => setResultModalVisible(false)}
>
{renderer ? (
React.createElement(renderer, { payload, task: currentTask })
) : (
<div>{t(`No renderer available for this task type, payload: ${payload}`)}</div>
)}
</Modal>
);
};
const columns = [
{
title: t('Created at'),
@ -140,7 +92,7 @@ export const AsyncTasks = () => {
if (!title) {
return '-';
}
const collection = cm.getCollection(title.collection);
const actionTypeMap = {
export: t('Export'),
import: t('Import'),
@ -156,7 +108,7 @@ export const AsyncTasks = () => {
};
const taskTemplate = taskTypeMap[title.actionType] || `${actionText}`;
return taskTemplate.replace('{collection}', title.collection);
return taskTemplate.replace('{collection}', compile(collection?.title || title.collection));
},
},
{
@ -274,7 +226,7 @@ export const AsyncTasks = () => {
width: 180,
render: (_, record: any) => {
const actions = [];
const isTaskCancelling = cancellingTasks.has(record.taskId);
const isTaskCancelling = false;
if ((record.status.type === 'running' || record.status.type === 'pending') && record.cancelable) {
actions.push(
@ -282,7 +234,15 @@ export const AsyncTasks = () => {
key="cancel"
title={t('Confirm cancel')}
description={t('Confirm cancel description')}
onConfirm={() => handleCancelTask(record.taskId)}
onConfirm={async () => {
await api.request({
url: 'asyncTasks:cancel',
params: {
filterByTk: record.taskId,
},
});
refresh();
}}
okText={t('Confirm')}
cancelText={t('Cancel')}
disabled={isTaskCancelling}
@ -309,8 +269,16 @@ export const AsyncTasks = () => {
icon={<Icon type="DownloadOutlined" />}
onClick={() => {
const token = app.apiClient.auth.token;
const collection = cm.getCollection(record.title.collection);
const compiledTitle = compile(collection?.title);
const suffix = record?.title?.actionType === 'export-attachments' ? '-attachments.zip' : '.xlsx';
const fileText = `${compiledTitle}${suffix}`;
const filename =
record?.title?.actionType !== 'create migration' ? encodeURIComponent(fileText) : null;
const url = app.getApiUrl(
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${appInfo?.data?.name || app.name}`,
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${encodeURIComponent(
appInfo?.data?.name || app.name,
)}${filename ? `&filename=${filename}` : ''}`,
);
window.open(url);
}}
@ -325,7 +293,19 @@ export const AsyncTasks = () => {
type="link"
size="small"
icon={<Icon type="EyeOutlined" />}
onClick={() => showTaskResult(record)}
onClick={() => {
showTaskResult(record);
const { payload } = record.status;
const renderer = plugin.taskResultRendererManager.get(record.title.actionType);
Modal.info({
title: t('Task result'),
content: renderer ? (
React.createElement(renderer, { payload, task: record })
) : (
<div>{t(`No renderer available for this task type, payload: ${payload}`)}</div>
),
});
}}
>
{t('View result')}
</Button>,
@ -341,9 +321,22 @@ export const AsyncTasks = () => {
size="small"
icon={<Icon type="ExclamationCircleOutlined" />}
onClick={() => {
setCurrentError(record.status.errors);
setModalVisible(true);
setPopoverVisible(false);
Modal.info({
title: t('Error Details'),
content: record.status.errors?.map((error, index) => (
<div key={index} style={{ marginBottom: 16 }}>
<div style={{ color: '#ff4d4f', marginBottom: 8 }}>{error.message}</div>
{error.code && (
<div style={{ color: '#999', fontSize: 12 }}>
{t('Error code')}: {error.code}
</div>
)}
</div>
)),
closable: true,
width: 400,
});
}}
>
{t('Error details')}
@ -357,9 +350,9 @@ export const AsyncTasks = () => {
];
const content = (
<div style={{ width: tasks.length > 0 ? 800 : 200 }}>
<div style={{ maxHeight: '70vh', overflow: 'auto', width: tasks.length > 0 ? 800 : 200 }}>
{tasks.length > 0 ? (
<Table columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
<Table loading={loading} columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
) : (
<div style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
<Empty description={t('No tasks')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
@ -383,30 +376,53 @@ export const AsyncTasks = () => {
onClick={() => setPopoverVisible(!popoverVisible)}
/>
</Popover>
{renderTaskResultModal()}
<Modal
title={t('Error Details')}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={[
<Button key="ok" type="primary" onClick={() => setModalVisible(false)}>
{t('OK')}
</Button>,
]}
width={400}
>
{currentError?.map((error, index) => (
<div key={index} style={{ marginBottom: 16 }}>
<div style={{ color: '#ff4d4f', marginBottom: 8 }}>{error.message}</div>
{error.code && (
<div style={{ color: '#999', fontSize: 12 }}>
{t('Error code')}: {error.code}
</div>
)}
</div>
))}
</Modal>
</>
);
};
export const AsyncTasks = () => {
const { tasks, refresh, ...others } = useAsyncTask();
const app = useApp();
const [popoverVisible, setPopoverVisible] = useState(false);
const [hasProcessingTasks, setHasProcessingTasks] = useState(false);
useEffect(() => {
setHasProcessingTasks(tasks.some((task) => task.status.type !== 'success' && task.status.type !== 'failed'));
}, [tasks]);
const handleTaskCreated = useCallback(async () => {
setPopoverVisible(true);
}, []);
const handleTaskProgress = useCallback(() => {
refresh();
console.log('handleTaskProgress');
}, []);
const handleTaskStatus = useCallback(() => {
refresh();
console.log('handleTaskStatus');
}, []);
const handleTaskCancelled = useCallback(() => {
refresh();
console.log('handleTaskCancelled');
}, []);
useEffect(() => {
app.eventBus.addEventListener('ws:message:async-tasks:created', handleTaskCreated);
app.eventBus.addEventListener('ws:message:async-tasks:progress', handleTaskProgress);
app.eventBus.addEventListener('ws:message:async-tasks:status', handleTaskStatus);
app.eventBus.addEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
return () => {
app.eventBus.removeEventListener('ws:message:async-tasks:created', handleTaskCreated);
app.eventBus.removeEventListener('ws:message:async-tasks:progress', handleTaskProgress);
app.eventBus.removeEventListener('ws:message:async-tasks:status', handleTaskStatus);
app.eventBus.removeEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
};
}, [app, handleTaskCancelled, handleTaskCreated, handleTaskProgress, handleTaskStatus]);
return (
tasks?.length > 0 && (
<AsyncTasksButton {...{ tasks, refresh, popoverVisible, setPopoverVisible, hasProcessingTasks, ...others }} />
)
);
};

View File

@ -1,9 +1,18 @@
/**
* 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 { Plugin } from '@nocobase/server';
import { BaseTaskManager } from './base-task-manager';
import { AsyncTasksManager } from './interfaces/async-task-manager';
import { CommandTaskType } from './command-task-type';
import asyncTasksResource from './resourcers/async-tasks';
import { throttle } from 'lodash';
import { BaseTaskManager } from './base-task-manager';
import { CommandTaskType } from './command-task-type';
import { AsyncTasksManager } from './interfaces/async-task-manager';
import asyncTasksResource from './resourcers/async-tasks';
export class PluginAsyncExportServer extends Plugin {
private progressThrottles: Map<string, Function> = new Map();
@ -20,7 +29,7 @@ export class PluginAsyncExportServer extends Plugin {
});
this.app.container.get<AsyncTasksManager>('AsyncTaskManager').registerTaskType(CommandTaskType);
this.app.acl.allow('asyncTasks', ['get', 'fetchFile'], 'loggedIn');
this.app.acl.allow('asyncTasks', ['list', 'get', 'fetchFile', 'cancel'], 'loggedIn');
}
getThrottledProgressEmitter(taskId: string, userId: string) {

View File

@ -1,8 +1,26 @@
/**
* 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 fs from 'fs';
import _ from 'lodash';
import { basename } from 'path';
export default {
name: 'asyncTasks',
actions: {
async list(ctx, next) {
const userId = ctx.auth.user.id;
const asyncTaskManager = ctx.app.container.get('AsyncTaskManager');
const tasks = await asyncTaskManager.getTasksByTag('userId', userId);
ctx.body = _.orderBy(tasks, 'createdAt', 'desc');
await next();
},
async get(ctx, next) {
const { filterByTk } = ctx.action.params;
const taskManager = ctx.app.container.get('AsyncTaskManager');
@ -11,8 +29,29 @@ export default {
ctx.body = taskStatus;
await next();
},
async fetchFile(ctx, next) {
async cancel(ctx, next) {
const { filterByTk } = ctx.action.params;
const userId = ctx.auth.user.id;
const asyncTaskManager = ctx.app.container.get('AsyncTaskManager');
const task = asyncTaskManager.getTask(filterByTk);
if (!task) {
ctx.body = 'ok';
await next();
return;
}
if (task.tags['userId'] != userId) {
ctx.throw(403);
}
const cancelled = await asyncTaskManager.cancelTask(filterByTk);
ctx.body = cancelled;
await next();
},
async fetchFile(ctx, next) {
const { filterByTk, filename } = ctx.action.params;
const taskManager = ctx.app.container.get('AsyncTaskManager');
const taskStatus = await taskManager.getTaskStatus(filterByTk);
// throw error if task is not success
@ -28,10 +67,12 @@ export default {
// send file to client
ctx.body = fs.createReadStream(filePath);
// 处理文件名
let finalFileName = filename ? filename : basename(filePath);
finalFileName = encodeURIComponent(finalFileName); // 避免中文或特殊字符问题
ctx.set({
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename=${basename(filePath)}`,
'Content-Disposition': `attachment; filename=${finalFileName}`,
});
await next();

View File

@ -9,13 +9,13 @@
import { FileImageOutlined, LeftOutlined } from '@ant-design/icons';
import { useActionContext } from '@nocobase/client';
import { Html5Qrcode } from 'html5-qrcode';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScanBox } from './ScanBox';
import { useScanner } from './useScanner';
const qrcodeEleId = 'qrcode';
export const QRCodeScannerInner = (props) => {
export const QRCodeScannerInner = ({ setVisible }) => {
const containerRef = useRef<HTMLDivElement>();
const imgUploaderRef = useRef<HTMLInputElement>();
const { t } = useTranslation('block-workbench');
@ -23,9 +23,17 @@ export const QRCodeScannerInner = (props) => {
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const onScanSuccess = useCallback(
(text) => {
setVisible(false);
},
[setVisible],
);
const { startScanFile } = useScanner({
onScannerSizeChanged: setOriginVideoSize,
elementId: qrcodeEleId,
onScanSuccess,
});
const getBoxStyle = (): React.CSSProperties => {
@ -174,7 +182,7 @@ export const QRCodeScanner = (props) => {
return visible && cameraAvaliable ? (
<div style={style}>
<QRCodeScannerInner />
<QRCodeScannerInner setVisible={setVisible} />
<LeftOutlined style={backIconStyle} onClick={() => setVisible(false)} />
<div style={titleStyle}>{t('Scan QR code')}</div>
</div>

View File

@ -20,7 +20,7 @@ function removeStringIfStartsWith(text: string, prefix: string): string {
return text;
}
export function useScanner({ onScannerSizeChanged, elementId }) {
export function useScanner({ onScannerSizeChanged, elementId, onScanSuccess }) {
const app = useApp();
const mobileManager = app.pm.get(MobileManager);
const basename = mobileManager.mobileRouter.basename.replace(/\/+$/, '');
@ -50,12 +50,17 @@ export function useScanner({ onScannerSizeChanged, elementId }) {
},
},
(text) => {
if (text?.startsWith('http')) {
window.location.href = text;
return;
}
navigate(removeStringIfStartsWith(text, basename));
onScanSuccess && onScanSuccess(text);
},
undefined,
);
},
[navigate, onScannerSizeChanged, viewPoint, basename],
[navigate, onScannerSizeChanged, viewPoint, basename, onScanSuccess],
);
const stopScanner = useCallback(async (scanner: Html5Qrcode) => {
const state = scanner.getState();
@ -69,13 +74,18 @@ export function useScanner({ onScannerSizeChanged, elementId }) {
await stopScanner(scanner);
try {
const { decodedText } = await scanner.scanFileV2(file, false);
if (decodedText?.startsWith('http')) {
window.location.href = decodedText;
return;
}
navigate(removeStringIfStartsWith(decodedText, basename));
onScanSuccess && onScanSuccess(decodedText);
} catch (error) {
alert(t('QR code recognition failed, please scan again'));
startScanCamera(scanner);
}
},
[stopScanner, scanner, navigate, basename, t, startScanCamera],
[stopScanner, scanner, navigate, basename, t, startScanCamera, onScanSuccess],
);
useEffect(() => {

View File

@ -108,7 +108,7 @@ const useEvents = (
title: string;
},
date: Date,
view: (typeof Weeks)[number],
view: (typeof Weeks)[number] | any = 'month',
) => {
const parseExpression = useLazy<typeof import('cron-parser').parseExpression>(
() => import('cron-parser'),
@ -132,8 +132,8 @@ const useEvents = (
const intervalTime = end.diff(start, 'millisecond', true);
const dateM = dayjs(date);
const startDate = dateM.clone().startOf('month');
const endDate = startDate.clone().endOf('month');
const startDate = dateM.clone().startOf(view);
const endDate = startDate.clone().endOf(view);
/**
* view === month
@ -425,7 +425,6 @@ export const Calendar: any = withDynamicSchemaProps(
};
};
const BigCalendar = reactBigCalendar?.BigCalendar;
return wrapSSR(
<div className={`${hashId} ${containerClassName}`} style={{ height: height || 700 }}>
<PopupContextProvider visible={visible} setVisible={setVisible}>

View File

@ -55,6 +55,12 @@ describe('Web client desktopRoutes', async () => {
},
});
await rootAgent.resource('roles').setSystemRoleMode({
values: {
roleMode: SystemRoleMode.allowUseUnion,
},
});
agent = await app.agent().login(user, UNION_ROLE_KEY);
});

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-locale-tester

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,13 @@
{
"name": "@nocobase/plugin-locale-tester",
"displayName": "Locale tester",
"displayName.zh-CN": "翻译测试工具",
"version": "1.7.0-alpha.10",
"homepage": "https://github.com/nocobase/locales",
"main": "dist/server/index.js",
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,249 @@
/**
* 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.
*/
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };
declare module '*.module.css' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.scss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sass' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.less' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.styl' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.stylus' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.pcss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sss' {
const classes: CSSModuleClasses;
export default classes;
}
// CSS
declare module '*.css' { }
declare module '*.scss' { }
declare module '*.sass' { }
declare module '*.less' { }
declare module '*.styl' { }
declare module '*.stylus' { }
declare module '*.pcss' { }
declare module '*.sss' { }
// Built-in asset types
// see `src/node/constants.ts`
// images
declare module '*.apng' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.jfif' {
const src: string;
export default src;
}
declare module '*.pjpeg' {
const src: string;
export default src;
}
declare module '*.pjp' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.ico' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.avif' {
const src: string;
export default src;
}
// media
declare module '*.mp4' {
const src: string;
export default src;
}
declare module '*.webm' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.flac' {
const src: string;
export default src;
}
declare module '*.aac' {
const src: string;
export default src;
}
declare module '*.opus' {
const src: string;
export default src;
}
declare module '*.mov' {
const src: string;
export default src;
}
declare module '*.m4a' {
const src: string;
export default src;
}
declare module '*.vtt' {
const src: string;
export default src;
}
// fonts
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.eot' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}
// other
declare module '*.webmanifest' {
const src: string;
export default src;
}
declare module '*.pdf' {
const src: string;
export default src;
}
declare module '*.txt' {
const src: string;
export default src;
}
// wasm?init
declare module '*.wasm?init' {
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
export default initWasm;
}
// web worker
declare module '*?worker' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&inline' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&url' {
const src: string;
export default src;
}
declare module '*?sharedworker' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&inline' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&url' {
const src: string;
export default src;
}
declare module '*?raw' {
const src: string;
export default src;
}
declare module '*?url' {
const src: string;
export default src;
}
declare module '*?inline' {
const src: string;
export default src;
}

View File

@ -0,0 +1,124 @@
/**
* 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 { useForm } from '@formily/react';
import { ActionProps, ISchema, Plugin, SchemaComponent, useAPIClient, useApp, useRequest } from '@nocobase/client';
import { Alert, App as AntdApp, Card, Spin } from 'antd';
import React from 'react';
import { useT } from './locale';
function LocaleTester() {
const { data, loading } = useRequest<any>({
url: 'localeTester:get',
});
const t = useT();
const schema: ISchema = {
type: 'void',
name: 'root',
properties: {
test: {
type: 'void',
'x-component': 'FormV2',
properties: {
locale: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input.JSON',
'x-component-props': {
autoSize: { minRows: 20, maxRows: 30 },
},
default: data?.data?.locale,
title: t('Translations'),
},
button: {
type: 'void',
'x-component': 'Action',
title: t('Submit'),
'x-use-component-props': 'useSubmitActionProps',
},
},
},
},
};
const useSubmitActionProps = () => {
const form = useForm();
const api = useAPIClient();
const { message } = AntdApp.useApp();
const app = useApp();
return {
type: 'primary',
htmlType: 'submit',
async onClick() {
await form.submit();
const values = form.values;
await api.request({
url: 'localeTester:updateOrCreate',
method: 'post',
params: {
filterKeys: ['id'],
},
data: {
id: data?.data?.id,
locale: values.locale,
},
});
message.success(app.i18n.t('Saved successfully!'));
window.location.reload();
},
};
};
if (loading) {
return <Spin />;
}
return (
<Card>
<Alert
style={{ marginBottom: 12 }}
description={
<div
dangerouslySetInnerHTML={{
__html: t(
`Please go to <a target="_blank" href="https://github.com/nocobase/locales">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation.`,
),
}}
></div>
}
/>
<SchemaComponent schema={schema} scope={{ useSubmitActionProps }} />
</Card>
);
}
export class PluginLocaleTesterClient extends Plugin {
async afterAdd() {
// await this.app.pm.add()
}
async beforeLoad() {}
// You can get and modify the app instance here
async load() {
this.app.pluginSettingsManager.add('locale-tester', {
title: this.t('Locale tester'),
icon: 'TranslationOutlined',
Component: LocaleTester,
});
// this.app.addComponents({})
// this.app.addScopes({})
// this.app.addProvider()
// this.app.addProviders()
// this.app.router.add()
}
}
export default PluginLocaleTesterClient;

View File

@ -0,0 +1,21 @@
/**
* 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.
*/
// @ts-ignore
import pkg from './../../package.json';
import { useApp } from '@nocobase/client';
export function useT() {
const app = useApp();
return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] });
}
export function tStr(key: string) {
return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`;
}

View File

@ -0,0 +1,11 @@
/**
* 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.
*/
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,5 @@
{
"Locale": "Locale",
"Locale tester": "Locale tester",
"Please go to <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation.": "Please go to <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation."
}

View File

@ -0,0 +1,5 @@
{
"Translations": "翻译",
"Locale tester": "翻译测试工具",
"Please go to <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation.": "请前往 <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> 获取需要翻译的语言文件,粘贴到下方并进行翻译。"
}

View File

@ -0,0 +1,21 @@
/**
* 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 { defineCollection } from '@nocobase/database';
export default defineCollection({
name: 'localeTester',
autoGenId: true,
fields: [
{
type: 'json',
name: 'locale',
},
],
});

View File

@ -0,0 +1,10 @@
/**
* 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.
*/
export { default } from './plugin';

View File

@ -0,0 +1,59 @@
/**
* 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 { Plugin } from '@nocobase/server';
import _ from 'lodash';
export class PluginLocaleTesterServer extends Plugin {
async afterAdd() {}
async beforeLoad() {
this.app.acl.registerSnippet({
name: `pm.${this.name}`,
actions: ['localeTester:*'],
});
}
async load() {
this.app.resourceManager.use(async (ctx, next) => {
await next();
const { resourceName, actionName } = ctx.action;
if (resourceName === 'app' && actionName === 'getLang') {
const repository = this.db.getRepository('localeTester');
const record = await repository.findOne();
const locale = record?.locale || {};
if (locale['cronstrue']) {
_.set(ctx.body, 'cronstrue', locale['cronstrue']);
}
if (locale['react-js-cron']) {
_.set(ctx.body, 'cron', locale['react-js-cron']);
}
Object.keys(locale).forEach((key) => {
if (key === 'cronstrue' || key === 'react-js-cron') {
return;
}
const value = locale[key];
_.set(ctx.body, ['resources', key], value);
const k = key.replace('@nocobase/', '').replace('@nocobase/plugin-', '');
_.set(ctx.body, ['resources', k], value);
});
}
});
}
async install() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginLocaleTesterServer;

View File

@ -54,7 +54,11 @@ describe('union role mobileRoutes', async () => {
roles: [role1.name, role2.name],
},
});
await rootAgent.resource('roles').setSystemRoleMode({
values: {
roleMode: SystemRoleMode.allowUseUnion,
},
});
agent = await app.agent().login(user, UNION_ROLE_KEY);
});

View File

@ -34,8 +34,16 @@ import {
} from '../../observables';
import InfiniteScrollContent from './InfiniteScrollContent';
function removeStringIfStartsWith(text: string, prefix: string): string {
if (text.startsWith(prefix)) {
return text.slice(prefix.length);
}
return text;
}
const MobileMessagePageInner = (props: { displayPageHeader?: boolean }) => {
const app = useApp();
const basename = app.router.basename.replace(/\/+$/, '');
const { t } = useLocalTranslation();
const navigate = useNavigate();
const ctx = useCurrentUserContext();
@ -61,7 +69,7 @@ const MobileMessagePageInner = (props: { displayPageHeader?: boolean }) => {
if (url) {
if (url.startsWith('/m/')) navigate(url.substring(2));
else if (url.startsWith('/')) {
navigate(url);
navigate(removeStringIfStartsWith(url, basename));
inboxVisible.value = false;
} else {
window.location.href = url;

View File

@ -44,6 +44,7 @@
"@nocobase/plugin-gantt": "1.7.0-alpha.10",
"@nocobase/plugin-graph-collection-manager": "1.7.0-alpha.10",
"@nocobase/plugin-kanban": "1.7.0-alpha.10",
"@nocobase/plugin-locale-tester": "1.7.0-alpha.10",
"@nocobase/plugin-localization": "1.7.0-alpha.10",
"@nocobase/plugin-logger": "1.7.0-alpha.10",
"@nocobase/plugin-map": "1.7.0-alpha.10",