feat: localization management (#2210)

* feat: init localization-management

* feat: resource api

* Merge branch 'main' into T-62

* chore: change name

* feat: basic feature

* feat: support filter & sync

* feat: support auto get texts afterSave

* Merge branch 'main' into T-62

* chore: upgrade

* fix: dependency

* fix: field type

* fix: type error

* chore: remove some translations

* feat: support extract text from menu

* chore: cache text keys

* chore: remove test key

* fix: issue of extracting menu titles

* feat: translate collections & fields name

* fix: remove unique of text

* refactor: improve cache

* chore: remove listeners after disable

* chore: translation

* fix: lang switch bug

* refactor: actions & filter

* fix: translation

* refactor: merge lang bundles at backend

* fix: style & field name

* fix: translate issues

* fix: cache bug

* fix: translation merge bug

* fix: translate issues

* fix: map translation

* fix: translation issues

* fix: card title bug

* feat: cover mobile client tabbar

* fix: menu title

* refactor: add locale plugin

* chore: merge locale plugin

* fix: map translation

* chore: remove no data

* style: change button style

* fix: sync bug

* docs: add README

* chore: change name

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
YANG QIA 2023-07-17 23:23:44 +08:00 committed by GitHub
parent 4e84b14bc7
commit 70d5b9e44b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1977 additions and 3796 deletions

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-localization-management/client';

View File

@ -98,7 +98,7 @@ export class AuthManager {
ctx.auth = authenticator;
} catch (err) {
ctx.auth = {} as Auth;
ctx.app.logger.warn(`auth, ${err.message}, ${err.stack}`);
ctx.app.logger.warn(`auth, ${err.message}`);
return next();
}
if (authenticator) {

View File

@ -86,6 +86,24 @@ export const MenuConfigure = () => {
}
message.success(t('Saved successfully'));
};
const translateTitle = (menus: any[]) => {
return menus.map((menu) => {
const title = t(menu.title);
if (menu.children) {
return {
...menu,
title,
children: translateTitle(menu.children),
};
}
return {
...menu,
title,
};
});
};
return (
<Table
className={styles}
@ -129,7 +147,7 @@ export const MenuConfigure = () => {
},
},
]}
dataSource={items}
dataSource={translateTitle(items)}
/>
);
};

View File

@ -134,7 +134,7 @@ export const SigninPage = () => {
`}
>
{tabs.length > 1 ? (
<Tabs items={tabs.map((tab) => ({ label: tab.tabTitle, key: tab.name, children: tab.component }))} />
<Tabs items={tabs.map((tab) => ({ label: t(tab.tabTitle), key: tab.name, children: tab.component }))} />
) : tabs.length ? (
<div>{tabs[0].component}</div>
) : (

View File

@ -1,6 +1,7 @@
import { Spin } from 'antd';
import { keyBy } from 'lodash';
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../api-client';
import { templateOptions } from '../collection-manager/Configuration/templates';
import { useCollectionHistory } from './CollectionHistoryProvider';
@ -31,6 +32,7 @@ export const CollectionManagerProvider: React.FC<CollectionManagerOptions> = (pr
};
export const RemoteCollectionManagerProvider = (props: any) => {
const { t } = useTranslation();
const api = useAPIClient();
const [contentLoading, setContentLoading] = useState(false);
const { refreshCH } = useCollectionHistory();
@ -85,11 +87,32 @@ export const RemoteCollectionManagerProvider = (props: any) => {
service.mutate({ data: collection });
};
const collections = (service?.data?.data || []).map(({ rawTitle, title, fields, ...collection }) => ({
...collection,
title: rawTitle ? title : t(title),
rawTitle: rawTitle || title,
fields: fields.map(({ uiSchema, ...field }) => {
if (uiSchema?.title) {
const title = uiSchema.title;
uiSchema.title = uiSchema.rawTitle ? title : t(title);
uiSchema.rawTitle = uiSchema.rawTitle || title;
}
if (uiSchema?.enum) {
uiSchema.enum = uiSchema.enum.map((item) => ({
...item,
label: item.rawLabel ? item.label : t(item.label),
rawLabel: item.rawLabel || item.label,
}));
}
return { uiSchema, ...field };
}),
}));
return (
<CollectionCategroriesProvider service={{ ...result }} refreshCategory={refreshCategory}>
<CollectionManagerProvider
service={{ ...service, contentLoading, setContentLoading }}
collections={service?.data?.data}
collections={collections}
refreshCM={refreshCM}
updateCollection={updateCollection}
{...props}

View File

@ -71,7 +71,7 @@ const CurrentFields = (props) => {
const columns: TableColumnProps<any>[] = [
{
dataIndex: ['uiSchema', 'title'],
dataIndex: ['uiSchema', 'rawTitle'],
title: t('Field display name'),
render: (value) => <div style={{ marginLeft: 7 }}>{compile(value)}</div>,
},
@ -177,7 +177,7 @@ const InheritFields = (props) => {
const columns: TableColumnProps<any>[] = [
{
dataIndex: ['uiSchema', 'title'],
dataIndex: ['uiSchema', 'rawTitle'],
title: t('Field display name'),
render: (value) => <div style={{ marginLeft: 1 }}>{compile(value)}</div>,
},

View File

@ -2,6 +2,7 @@ import { useForm } from '@formily/react';
import { action } from '@formily/reactive';
import { uid } from '@formily/shared';
import React, { useContext, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CollectionFieldsTable } from '.';
import { useAPIClient } from '../../api-client';
import { useCurrentAppInfo } from '../../appInfo';
@ -81,6 +82,7 @@ const useNewId = (prefix) => {
};
export const ConfigurationTable = () => {
const { t } = useTranslation();
const { collections = [], interfaces } = useCollectionManager();
const {
data: { database },
@ -149,7 +151,7 @@ export const ConfigurationTable = () => {
.then(({ data }) => {
return data?.data?.map((item: any) => {
return {
label: compile(item.title),
label: t(compile(item.title)),
value: item.name,
};
});

View File

@ -14,6 +14,7 @@ import { uid } from '@formily/shared';
import { App, Badge, Card, Dropdown, Tabs } from 'antd';
import _ from 'lodash';
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../api-client';
import { SchemaComponent, SchemaComponentOptions, useCompile } from '../../schema-component';
import { useResourceActionContext } from '../ResourceActionProvider';
@ -67,11 +68,12 @@ const TabTitle = observer(
);
const TabBar = ({ item }) => {
const { t } = useTranslation();
const compile = useCompile();
return (
<span>
<Badge color={item.color} />
{compile(item.name)}
{t(compile(item.name))}
</span>
);
};
@ -118,6 +120,7 @@ const DndProvider = observer(
{ displayName: 'DndProvider' },
);
export const ConfigurationTabs = () => {
const { t } = useTranslation();
const { data, refresh } = useContext(CollectionCategroriesContext);
const { refresh: refreshCM, run, defaultRequest, setState } = useResourceActionContext();
const [key, setKey] = useState('all');
@ -179,7 +182,7 @@ export const ConfigurationTabs = () => {
const loadCategories = async () => {
return data.map((item: any) => ({
label: compile(item.name),
label: t(compile(item.name)),
value: item.id,
}));
};

View File

@ -1,12 +1,12 @@
import { ISchema } from '@formily/react';
import { i18n } from '../../i18n';
import { defaultProps } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
export const markdown: IField = {
name: 'markdown',
type: 'object',
title: 'Markdown',
title: '{{t("Markdown")}}',
group: 'media',
default: {
type: 'text',

View File

@ -1,5 +1,6 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useTranslation } from 'react-i18next';
import { Plugin } from '../application/Plugin';
import { useSystemSettings } from '../system-settings';
@ -15,9 +16,10 @@ export const DocumentTitleContext = createContext<DocumentTitleContextProps>({
export const DocumentTitleProvider: React.FC<{ addonBefore?: string; addonAfter?: string }> = (props) => {
const { addonBefore, addonAfter } = props;
const { t } = useTranslation();
const [title, setTitle] = useState('');
const documentTitle = `${addonBefore ? ` - ${addonBefore}` : ''}${title || ''}${
addonAfter ? ` - ${addonAfter}` : ''
const documentTitle = `${addonBefore ? ` - ${t(addonBefore)}` : ''}${t(title || '')}${
addonAfter ? ` - ${t(addonAfter)}` : ''
}`;
return (
<DocumentTitleContext.Provider

View File

@ -17,6 +17,7 @@ i18n
lng: localStorage.getItem('NOCOBASE_LOCALE') || 'en-US',
// debug: true,
defaultNS: 'client',
fallbackNS: 'client',
// backend: {
// // for all available options read the backend's repository readme file
// loadPath: '/api/locales/{{lng}}/{{ns}}.json',

View File

@ -30,6 +30,7 @@ export * from './global-theme';
export * from './hooks';
export * from './i18n';
export * from './icon';
export { default as locale } from './locale';
export * from './nocobase-buildin-plugin';
export * from './plugin-manager';
export * from './pm';

View File

@ -3,7 +3,6 @@ import { Button, Result, Spin } from 'antd';
import React, { FC } from 'react';
import { Navigate } from 'react-router-dom';
import { ACLPlugin } from '../acl';
import { AntdConfigPlugin } from '../antd-config-provider';
import { Application } from '../application';
import { Plugin } from '../application/Plugin';
import { SigninPage, SigninPageExtensionPlugin, SignupPage } from '../auth';
@ -20,6 +19,7 @@ import { SchemaInitializerPlugin } from '../schema-initializer';
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
import { SystemSettingsPlugin } from '../system-settings';
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
import { LocalePlugin } from './plugins/LocalePlugin';
const AppSpin = Spin;
@ -45,13 +45,14 @@ const AppError: FC<{ app: Application; error: Error }> = ({ app, error }) => (
);
export class NocoBaseBuildInPlugin extends Plugin {
async afterAdd(): Promise<void> {
async afterAdd() {
this.app.addComponents({
AppSpin,
AppError,
});
this.addPlugins();
}
async load() {
this.addComponents();
this.addRoutes();
@ -62,6 +63,7 @@ export class NocoBaseBuildInPlugin extends Plugin {
this.app.use(CSSVariableProvider);
this.app.use(CurrentUserSettingsMenuProvider);
}
addRoutes() {
this.router.add('root', {
path: '/',
@ -102,8 +104,8 @@ export class NocoBaseBuildInPlugin extends Plugin {
});
}
addPlugins() {
this.app.pm.add(LocalePlugin, { name: 'locale' });
this.app.pm.add(AdminLayoutPlugin, { name: 'admin-layout' });
this.app.pm.add(AntdConfigPlugin, { name: 'antd-config', config: { remoteLocale: true } });
this.app.pm.add(SystemSettingsPlugin, { name: 'system-setting' });
this.app.pm.add(PinnedListPlugin, {
name: 'pinned-list',

View File

@ -0,0 +1,35 @@
import { dayjs } from '@nocobase/utils/client';
import { ConfigProvider } from 'antd';
import { loadConstrueLocale } from '../../antd-config-provider/loadConstrueLocale';
import { Plugin } from '../../application/Plugin';
export class LocalePlugin extends Plugin {
locales: any = {};
async afterAdd() {
const api = this.app.apiClient;
const locale = api.auth.locale;
try {
const { data } = await api.request({
url: 'app:getLang',
params: {
locale,
},
});
this.locales = data?.data || {};
this.app.use(ConfigProvider, { locale: this.locales.antd, popupMatchSelectWidth: false });
if (data?.data?.lang && !locale) {
api.auth.setLocale(data?.data?.lang);
this.app.i18n.changeLanguage(data?.data?.lang);
}
Object.keys(data?.data?.resources || {}).forEach((key) => {
this.app.i18n.addResources(data?.data?.lang, key, data?.data?.resources[key] || {});
});
loadConstrueLocale(data?.data);
dayjs.locale(data?.data?.moment);
window['cronLocale'] = data?.data?.cron;
} catch (error) {
(() => {})();
throw error;
}
}
}

View File

@ -3,6 +3,7 @@ import { observer, RecursionField, useField, useFieldSchema, useForm } from '@fo
import { App, Button, Popover } from 'antd';
import classnames from 'classnames';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useActionContext } from '../..';
import { useDesignable } from '../../';
import { Icon } from '../../../icon';
@ -79,6 +80,7 @@ export const Action: ComposedAction = observer(
title,
...others
} = props;
const { t } = useTranslation();
const { onClick } = useProps(props);
const [visible, setVisible] = useState(false);
const [formValueChanged, setFormValueChanged] = useState(false);
@ -146,7 +148,7 @@ export const Action: ComposedAction = observer(
className={classnames(actionDesignerCss, className)}
type={props.type === 'danger' ? undefined : props.type}
>
{title || compile(fieldSchema.title)}
{t(title || compile(fieldSchema.title))}
<Designer {...designerProps} />
</SortableItem>
);

View File

@ -63,7 +63,7 @@ const useDataTemplates = () => {
key: 'none',
title: t('None'),
},
].concat(items.map<any>((t, i) => ({ key: i, ...t })));
].concat(items.map<any>((item, i) => ({ key: i, ...item })));
const defaultTemplate = items.find((item) => item.default);
return {

View File

@ -47,6 +47,16 @@ const InsertMenuItems = (props) => {
if (!isSubMenu && insertPosition === 'beforeEnd') {
return null;
}
const serverHooks = [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
];
return (
<SchemaSettings.SubMenu eventKey={eventKey} title={title}>
<SchemaSettings.ModalItem
@ -82,12 +92,7 @@ const InsertMenuItems = (props) => {
'x-component-props': {
icon,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
'x-server-hooks': serverHooks,
});
}}
/>
@ -123,12 +128,7 @@ const InsertMenuItems = (props) => {
'x-component-props': {
icon,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
'x-server-hooks': serverHooks,
properties: {
page: {
type: 'void',
@ -184,12 +184,7 @@ const InsertMenuItems = (props) => {
icon,
href,
},
'x-server-hooks': [
{
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
],
'x-server-hooks': serverHooks,
});
}}
/>
@ -263,6 +258,12 @@ export const MenuDesigner = () => {
onSubmit={({ title, icon, href }) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
'x-server-hooks': [
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
],
};
if (title) {
fieldSchema.title = title;

View File

@ -463,6 +463,7 @@ export const Menu: ComposedMenu = observer(
Menu.Item = observer(
(props) => {
const { t } = useTranslation();
const { pushMenuItem } = useCollectMenuItems();
const { icon, children, ...others } = props;
const schema = useFieldSchema();
@ -489,7 +490,7 @@ Menu.Item = observer(
verticalAlign: 'middle',
}}
>
{field.title}
{t(field.title)}
</span>
<Designer />
</SortableItem>
@ -512,6 +513,7 @@ Menu.Item = observer(
Menu.URL = observer(
(props) => {
const { t } = useTranslation();
const { pushMenuItem } = useCollectMenuItems();
const { icon, children, ...others } = props;
const schema = useFieldSchema();
@ -547,7 +549,7 @@ Menu.URL = observer(
verticalAlign: 'middle',
}}
>
{field.title}
{t(field.title)}
</span>
<Designer />
</SortableItem>
@ -565,6 +567,7 @@ Menu.URL = observer(
Menu.SubMenu = observer(
(props) => {
const { t } = useTranslation();
const { Component, getMenuItems } = useMenuItem();
const { pushMenuItem } = useCollectMenuItems();
const { icon, children, ...others } = props;
@ -583,7 +586,7 @@ Menu.SubMenu = observer(
<FieldContext.Provider value={field}>
<SortableItem className={subMenuDesignerCss} removeParentsIfNoChildren={false}>
<Icon type={icon} />
{field.title}
{t(field.title)}
<Designer />
</SortableItem>
</FieldContext.Provider>

View File

@ -93,6 +93,10 @@ export const GroupItem = itemWrap((props) => {
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
],
});
}, [theme]);
@ -152,6 +156,10 @@ export const PageMenuItem = itemWrap((props) => {
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
],
properties: {
page: {
@ -232,6 +240,10 @@ export const LinkMenuItem = itemWrap((props) => {
type: 'onSelfCreate',
method: 'bindMenuToRole',
},
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
],
});
}, [theme]);

View File

@ -25,6 +25,7 @@ import { useStyles } from './style';
export const Page = (props) => {
const { children, ...others } = props;
const { t } = useTranslation();
const compile = useCompile();
const { title, setTitle } = useDocumentTitle();
const fieldSchema = useFieldSchema();
@ -41,13 +42,12 @@ export const Page = (props) => {
useEffect(() => {
if (!title) {
setTitle(fieldSchema.title);
setTitle(t(fieldSchema.title));
}
}, [fieldSchema.title, title]);
const disablePageHeader = fieldSchema['x-component-props']?.disablePageHeader;
const enablePageTabs = fieldSchema['x-component-props']?.enablePageTabs;
const hidePageTitle = fieldSchema['x-component-props']?.hidePageTitle;
const { t } = useTranslation();
const options = useContext(SchemaOptionsContext);
const [searchParams, setSearchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
@ -77,7 +77,7 @@ export const Page = (props) => {
className={classNames('pageHeaderCss', pageHeaderTitle || enablePageTabs ? '' : 'height0')}
ghost={false}
// 如果标题为空的时候会导致 PageHeader 不渲染,所以这里设置一个空白字符,然后再设置高度为 0
title={pageHeaderTitle || ' '}
title={t(pageHeaderTitle || ' ')}
{...others}
footer={
enablePageTabs && (

View File

@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { Tabs as AntdTabs, TabPaneProps, TabsProps } from 'antd';
import { TabPaneProps, Tabs as AntdTabs, TabsProps } from 'antd';
import classNames from 'classnames';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../../icon';
import { useSchemaInitializer } from '../../../schema-initializer';
import { DndContext, SortableItem } from '../../common';

View File

@ -1,6 +1,6 @@
import merge from 'deepmerge';
import { EventEmitter } from 'events';
import { default as lodash, default as _ } from 'lodash';
import { default as _, default as lodash } from 'lodash';
import {
ModelOptions,
ModelStatic,
@ -399,7 +399,6 @@ export class Collection<
updateOptions(options: CollectionOptions, mergeOptions?: any) {
let newOptions = lodash.cloneDeep(options);
newOptions = merge(this.options, newOptions, mergeOptions);
this.context.database.emit('beforeUpdateCollection', this, newOptions);
this.options = newOptions;
@ -409,7 +408,6 @@ export class Collection<
}
this.context.database.emit('afterUpdateCollection', this);
return this;
}

View File

@ -1,5 +1,4 @@
import _ from 'lodash';
import {
DataType,
ModelAttributeColumnOptions,
@ -22,6 +21,7 @@ export interface FieldContext {
export interface BaseFieldOptions {
name?: string;
hidden?: boolean;
translation?: boolean;
[key: string]: any;
}

View File

@ -36,6 +36,7 @@ export default {
title: '{{t("Role name")}}',
'x-component': 'Input',
},
translation: true,
},
{
type: 'boolean',
@ -85,5 +86,16 @@ export default {
name: 'snippets',
defaultValue: ['!ui.*', '!pm', '!pm.*'],
},
{
type: 'belongsToMany',
name: 'users',
target: 'users',
foreignKey: 'roleName',
otherKey: 'userId',
onDelete: 'CASCADE',
sourceKey: 'name',
targetKey: 'id',
through: 'rolesUsers',
},
],
} as CollectionOptions;

View File

@ -1,8 +1,8 @@
import { Context, Next } from '@nocobase/actions';
import { namespace } from '../../preset';
import { Model, Repository } from '@nocobase/database';
import { namespace } from '../../preset';
async function checkCount(repository: Repository, id: number) {
async function checkCount(repository: Repository, id: number[]) {
// TODO(yangqia): This is a temporary solution, may cause concurrency problem.
const count = await repository.count({
filter: {

View File

@ -56,6 +56,7 @@ export default {
title: '{{t("Title")}}',
'x-component': 'Input',
},
translation: true,
},
{
interface: 'textarea',

View File

@ -1 +1 @@
export { default } from './server';
export { default, getResourceLocale } from './server';

View File

@ -1 +1,2 @@
export { getResourceLocale } from './resource';
export { default } from './server';

View File

@ -13,6 +13,7 @@ export default {
{
type: 'string',
name: 'name',
translation: true,
},
{
type: 'string',

View File

@ -27,6 +27,7 @@ export default {
type: 'string',
name: 'title',
required: true,
translation: true,
},
{
type: 'boolean',

View File

@ -63,6 +63,7 @@ export default {
type: 'json',
name: 'options',
defaultValue: {},
translation: true,
},
],
} as CollectionOptions;

View File

@ -154,7 +154,7 @@ export const querySchema: ISchema = {
'x-component-props': {
options: '{{ collectionOptions }}',
onChange: '{{ onCollectionChange }}',
placeholder: lang('Collection'),
placeholder: '{{t("Collection")}}',
},
},
},
@ -206,11 +206,11 @@ export const querySchema: ISchema = {
placeholder: '{{t("Aggregation")}}',
},
enum: [
{ label: lang('Sum'), value: 'sum' },
{ label: lang('Count'), value: 'count' },
{ label: lang('Avg'), value: 'avg' },
{ label: lang('Max'), value: 'max' },
{ label: lang('Min'), value: 'min' },
{ label: '{{t("Sum")}}', value: 'sum' },
{ label: '{{t("Count")}}', value: 'count' },
{ label: '{{t("Avg")}}', value: 'avg' },
{ label: '{{t("Max")}}', value: 'max' },
{ label: '{{t("Min")}}', value: 'min' },
],
},
alias: {

View File

@ -14,7 +14,7 @@ const Chart: React.FC = (props) => {
children.push({
key: 'chart-v2',
type: 'item',
title: t('Chart'),
title: t('Charts'),
component: 'ChartV2BlockInitializer',
});
}

View File

@ -1,10 +1,9 @@
import { i18n } from '@nocobase/client';
import { useTranslation } from 'react-i18next';
import zhCN from './zh-CN';
export const NAMESPACE = 'charts-v2';
export const NAMESPACE = 'data-visualization';
i18n.addResources('zh-CN', NAMESPACE, zhCN);
// i18n.addResources('zh-CN', NAMESPACE, zhCN);
// i18n.addResources('en-US', NAMESPACE, enUS);
// i18n.addResources('ja-JP', NAMESPACE, jaJP);
// i18n.addResources('ru-RU', NAMESPACE, ruRU);

View File

@ -68,4 +68,5 @@ export default {
Min: '最小值',
Max: '最大值',
'Please select a chart type.': '请选择图表类型',
Collection: '数据表',
};

View File

@ -77,7 +77,7 @@ export const useChartTypes = (): {
const children = Object.entries(l.charts).map(([type, chart]) => ({
...chart,
key: type,
label: chart.name,
label: lang(chart.name),
value: type,
}));
return [

View File

@ -1,5 +1,4 @@
import { Area, Bar, Column, DualAxes, Line, Pie, Scatter } from '@ant-design/plots';
import { lang } from '../../locale';
import { Charts, commonInit, infer, usePropsFunc } from '../ChartLibrary';
const init = commonInit;
@ -7,7 +6,7 @@ const basicSchema = {
type: 'object',
properties: {
xField: {
title: lang('xField'),
title: '{{t("xField")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
@ -15,7 +14,7 @@ const basicSchema = {
required: true,
},
yField: {
title: lang('yField'),
title: '{{t("yField")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
@ -23,7 +22,7 @@ const basicSchema = {
required: true,
},
seriesField: {
title: lang('seriesField'),
title: '{{t("seriesField")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
@ -50,40 +49,40 @@ const useProps: usePropsFunc = ({ data, fieldProps, general, advanced }) => {
export const G2PlotLibrary: Charts = {
line: {
name: lang('Line Chart'),
name: 'Line Chart',
component: Line,
schema: basicSchema,
init,
useProps,
reference: {
title: lang('Line Chart'),
title: 'Line Chart',
link: 'https://g2plot.antv.antgroup.com/api/plots/bar',
},
},
area: {
name: lang('Area Chart'),
name: 'Area Chart',
component: Area,
schema: basicSchema,
init,
useProps,
reference: {
title: lang('Area Chart'),
title: 'Area Chart',
link: 'https://g2plot.antv.antgroup.com/api/plots/area',
},
},
column: {
name: lang('Column Chart'),
name: 'Column Chart',
component: Column,
schema: basicSchema,
init,
useProps,
reference: {
title: lang('Column Chart'),
title: 'Column Chart',
link: 'https://g2plot.antv.antgroup.com/api/plots/column',
},
},
bar: {
name: lang('Bar Chart'),
name: 'Bar Chart',
component: Bar,
schema: basicSchema,
init: (fields, { measures, dimensions }) => {
@ -98,18 +97,18 @@ export const G2PlotLibrary: Charts = {
},
useProps,
reference: {
title: lang('Bar Chart'),
title: 'Bar Chart',
link: 'https://g2plot.antv.antgroup.com/api/plots/bar',
},
},
pie: {
name: lang('Pie Chart'),
name: 'Pie Chart',
component: Pie,
schema: {
type: 'object',
properties: {
angleField: {
title: lang('angleField'),
title: '{{t("angleField")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
@ -117,7 +116,7 @@ export const G2PlotLibrary: Charts = {
required: true,
},
colorField: {
title: lang('colorField'),
title: '{{t("colorField")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
@ -137,12 +136,12 @@ export const G2PlotLibrary: Charts = {
},
useProps,
reference: {
title: lang('Pie Chart'),
title: 'Pie Chart',
link: 'https://g2plot.antv.antgroup.com/api/plots/pie',
},
},
dualAxes: {
name: lang('Dual Axes Chart'),
name: 'Dual Axes Chart',
component: DualAxes,
useProps: ({ data, fieldProps, general, advanced }) => {
return {
@ -154,7 +153,7 @@ export const G2PlotLibrary: Charts = {
type: 'object',
properties: {
xField: {
title: lang('xField'),
title: '{{t("xField")}}',
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
@ -162,7 +161,7 @@ export const G2PlotLibrary: Charts = {
required: true,
},
yField: {
title: lang('yField'),
title: '{{t("yField")}}',
type: 'array',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems',
@ -197,7 +196,7 @@ export const G2PlotLibrary: Charts = {
properties: {
add: {
type: 'void',
title: lang('Add'),
title: '{{t("Add")}}',
'x-component': 'ArrayItems.Addition',
},
},
@ -214,22 +213,22 @@ export const G2PlotLibrary: Charts = {
};
},
reference: {
title: lang('Dual Axes Chart'),
title: 'Dual Axes Chart',
link: 'https://g2plot.antv.antgroup.com/api/plots/dual-axes',
},
},
// gauge: {
// name: lang('Gauge Chart'),
// name: 'Gauge Chart',
// component: Gauge,
// },
scatter: {
name: lang('Scatter Chart'),
name: 'Scatter Chart',
component: Scatter,
schema: basicSchema,
init,
useProps,
reference: {
title: lang('Scatter Chart'),
title: 'Scatter Chart',
link: 'https://g2plot.antv.antgroup.com/api/plots/scatter',
},
},

View File

@ -11,6 +11,7 @@ export default {
comment: '存储引擎名称',
type: 'string',
name: 'title',
translation: true,
},
{
title: '英文标识',

View File

@ -0,0 +1,35 @@
# Localization Management
支持管理应用程序的多语言资源。
## 使用方法
### 在`系统设置`中添加对应语言
<img src="https://s2.loli.net/2023/07/17/1t958NfkEyMjhsm.png" width="550" />
### 切换到对应语言
<img src="https://s2.loli.net/2023/07/17/npNT1EsIAQcGH3W.png" width="300" />
### 管理多语言资源
1. 同步需要翻译的原文内容
<img src="https://s2.loli.net/2023/07/17/3Uqzdt6mfvauDEP.png" width="300" />
- 目前支持的内容
- 菜单
- 系统和插件提供的语言包
- 数据表名、字段名、字段选项标签
> **Note**
> 新增菜单、数据表名、字段名、字段选项标签会自动同步
> 已有内容需要点击同步按钮手动同步
2. 编辑翻译内容,点击`发布`按钮即可生效
<img src="https://s2.loli.net/2023/07/17/TVzmJt6ZNYg4fSo.png" width="400" />

View File

@ -0,0 +1,3 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -0,0 +1,65 @@
'use strict';
function _getRequireWildcardCache(nodeInterop) {
if (typeof WeakMap !== 'function') return null;
var cacheBabelInterop = new WeakMap();
var cacheNodeInterop = new WeakMap();
return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) {
return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
})(nodeInterop);
}
function _interopRequireWildcard(obj, nodeInterop) {
if (!nodeInterop && obj && obj.__esModule) {
return obj;
}
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return { default: obj };
}
var cache = _getRequireWildcardCache(nodeInterop);
if (cache && cache.has(obj)) {
return cache.get(obj);
}
var newObj = {};
var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;
for (var key in obj) {
if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) {
var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;
if (desc && (desc.get || desc.set)) {
Object.defineProperty(newObj, key, desc);
} else {
newObj[key] = obj[key];
}
}
}
newObj.default = obj;
if (cache) {
cache.set(obj, newObj);
}
return newObj;
}
var _index = _interopRequireWildcard(require('./lib/client'));
Object.defineProperty(exports, '__esModule', {
value: true,
});
var _exportNames = {};
Object.defineProperty(exports, 'default', {
enumerable: true,
get: function get() {
return _index.default;
},
});
Object.keys(_index).forEach(function (key) {
if (key === 'default' || key === '__esModule') return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
},
});
});

View File

@ -0,0 +1,21 @@
{
"name": "@nocobase/plugin-localization-management",
"version": "0.11.0-alpha.1",
"main": "lib/server/index.js",
"devDependencies": {
"@nocobase/cache": "0.11.0-alpha.1",
"@nocobase/client": "0.11.0-alpha.1",
"@nocobase/database": "0.11.0-alpha.1",
"@nocobase/server": "0.11.0-alpha.1",
"@nocobase/test": "0.11.0-alpha.1",
"@nocobase/plugin-client": "0.11.0-alpha.1",
"@nocobase/plugin-ui-schema-storage": "0.11.0-alpha.1"
},
"dependencies": {
"deepmerge": "^4.3.1"
},
"displayName": "Localization management",
"displayName.zh-CN": "多语言管理",
"description": "Allows to manage localization resources of the application.",
"description.zh-CN": "支持管理应用程序的多语言资源。"
}

View File

@ -0,0 +1,3 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -0,0 +1,65 @@
'use strict';
function _getRequireWildcardCache(nodeInterop) {
if (typeof WeakMap !== 'function') return null;
var cacheBabelInterop = new WeakMap();
var cacheNodeInterop = new WeakMap();
return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) {
return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
})(nodeInterop);
}
function _interopRequireWildcard(obj, nodeInterop) {
if (!nodeInterop && obj && obj.__esModule) {
return obj;
}
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return { default: obj };
}
var cache = _getRequireWildcardCache(nodeInterop);
if (cache && cache.has(obj)) {
return cache.get(obj);
}
var newObj = {};
var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;
for (var key in obj) {
if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) {
var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;
if (desc && (desc.get || desc.set)) {
Object.defineProperty(newObj, key, desc);
} else {
newObj[key] = obj[key];
}
}
}
newObj.default = obj;
if (cache) {
cache.set(obj, newObj);
}
return newObj;
}
var _index = _interopRequireWildcard(require('./lib/server'));
Object.defineProperty(exports, '__esModule', {
value: true,
});
var _exportNames = {};
Object.defineProperty(exports, 'default', {
enumerable: true,
get: function get() {
return _index.default;
},
});
Object.keys(_index).forEach(function (key) {
if (key === 'default' || key === '__esModule') return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
},
});
});

View File

@ -0,0 +1,253 @@
import { SyncOutlined } from '@ant-design/icons';
import { Form, createForm } from '@formily/core';
import { Field, useField, useForm } from '@formily/react';
import {
FormProvider,
Input,
Radio,
SchemaComponent,
locale,
useAPIClient,
useActionContext,
useRecord,
useResourceActionContext,
useResourceContext,
} from '@nocobase/client';
import { Input as AntdInput, Button, Card, Checkbox, Col, Divider, Popover, Row, Tag, Typography, message } from 'antd';
import React, { useMemo, useState } from 'react';
import { useLocalTranslation } from './locale';
import { localizationSchema } from './schemas/localization';
const { Text } = Typography;
const useUpdateTranslationAction = () => {
const field = useField();
const form = useForm();
const ctx = useActionContext();
const { refresh } = useResourceActionContext();
const { targetKey } = useResourceContext();
const { [targetKey]: textId } = useRecord();
const api = useAPIClient();
const locale = api.auth.getLocale();
return {
async run() {
await form.submit();
field.data = field.data || {};
field.data.loading = true;
try {
await api.resource('localizationTranslations').updateOrCreate({
filterKeys: ['textId', 'locale'],
values: {
textId,
locale,
translation: form.values.translation,
},
});
ctx.setVisible(false);
await form.reset();
refresh();
} catch (e) {
console.log(e);
} finally {
field.data.loading = false;
}
},
};
};
const useDestroyTranslationAction = () => {
const { refresh } = useResourceActionContext();
const api = useAPIClient();
const { translationId: filterByTk } = useRecord();
return {
async run() {
await api.resource('localizationTranslations').destroy({ filterByTk });
refresh();
},
};
};
const useBulkDestroyTranslationAction = () => {
const { state, setState, refresh } = useResourceActionContext();
const api = useAPIClient();
const { t } = useLocalTranslation();
return {
async run() {
if (!state?.selectedRowKeys?.length) {
return message.error(t('Please select the records you want to delete'));
}
await api.resource('localizationTranslations').destroy({ filterByTk: state?.selectedRowKeys });
setState?.({ selectedRowKeys: [] });
refresh();
},
};
};
const usePublishAction = () => {
const api = useAPIClient();
return {
async run() {
await api.resource('localization').publish();
window.location.reload();
},
};
};
const Sync = () => {
const { t } = useLocalTranslation();
const { refresh } = useResourceActionContext();
const api = useAPIClient();
const [loading, setLoading] = useState(false);
const plainOptions = ['local', 'menu', 'db'];
const [checkedList, setCheckedList] = useState<any[]>(plainOptions);
const [indeterminate, setIndeterminate] = useState(false);
const [checkAll, setCheckAll] = useState(true);
const onChange = (list: any[]) => {
setCheckedList(list);
setIndeterminate(!!list.length && list.length < plainOptions.length);
setCheckAll(list.length === plainOptions.length);
};
const onCheckAllChange = (e) => {
setCheckedList(e.target.checked ? plainOptions : []);
setIndeterminate(false);
setCheckAll(e.target.checked);
};
return (
<Popover
placement="bottomRight"
content={
<>
<Checkbox indeterminate={indeterminate} onChange={onCheckAllChange} checked={checkAll}>
{t('All')}
</Checkbox>
<Divider style={{ margin: '5px 0' }} />
<Checkbox.Group onChange={onChange} value={checkedList}>
<Col>
<Row>
<Checkbox value="local">{t('System & Plugins')}</Checkbox>
</Row>
<Row>
<Checkbox value="db">{t('Collections & Fields')}</Checkbox>
</Row>
<Row>
<Checkbox value="menu">{t('Menu')}</Checkbox>
</Row>
</Col>
</Checkbox.Group>
</>
}
>
<Button
icon={<SyncOutlined />}
loading={loading}
onClick={async () => {
if (!checkedList.length) {
return message.error(t('Please select the resources you want to synchronize'));
}
setLoading(true);
await api.resource('localization').sync({
values: {
type: checkedList,
},
});
setLoading(false);
refresh();
}}
>
{t('Sync')}
</Button>
</Popover>
);
};
const Filter = () => {
const { t } = useLocalTranslation();
const { run, refresh } = useResourceActionContext();
const api = useAPIClient();
const locale = api.auth.getLocale();
const form = useMemo<Form>(
() =>
createForm({
initialValues: {
hasTranslation: true,
},
}),
[],
);
const filter = (values?: any) => {
run({
...(values || form.values),
});
};
return (
<FormProvider form={form}>
<div style={{ display: 'flex' }}>
<Field
name="keyword"
component={[
AntdInput.Search,
{
placeholder: t('Keyword'),
allowClear: true,
style: {
width: 'fit-content',
},
onSearch: (keyword) => filter({ ...form.values, keyword }),
},
]}
/>
<Field
name="hasTranslation"
dataSource={[
{ label: t('All'), value: true },
{ label: t('No translation'), value: false },
]}
component={[
Radio.Group,
{
defaultValue: true,
style: {
marginLeft: '8px',
width: 'fit-content',
},
optionType: 'button',
onChange: () => filter(),
},
]}
/>
</div>
</FormProvider>
);
};
export const Localization = () => {
const { t } = useLocalTranslation();
const api = useAPIClient();
const curLocale = api.auth.getLocale();
const localeLabel = locale[curLocale]?.label || curLocale;
const CurrentLang = () => (
<Typography>
<Text strong>{t('Current language')}</Text>
<Tag style={{ marginLeft: '10px' }}>{localeLabel}</Tag>
</Typography>
);
const TranslationField = (props) => (props.value !== undefined ? <Input.TextArea {...props} /> : <div></div>);
return (
<Card bordered={false}>
<SchemaComponent
schema={localizationSchema}
components={{ TranslationField, CurrentLang, Sync, Filter }}
scope={{
t,
useDestroyTranslationAction,
useBulkDestroyTranslationAction,
useUpdateTranslationAction,
usePublishAction,
}}
/>
</Card>
);
};

View File

@ -0,0 +1,32 @@
import { Plugin, SettingsCenterProvider } from '@nocobase/client';
import React from 'react';
import { Localization } from './Localization';
import { useLocalTranslation } from './locale';
export class LocalizationManagementPlugin extends Plugin {
async load() {
this.app.use((props) => {
const { t } = useLocalTranslation();
return (
<SettingsCenterProvider
settings={{
['localization-management']: {
title: t('Localization management'),
icon: 'GlobalOutlined',
tabs: {
localization: {
title: t('Translations'),
component: () => <Localization />,
},
},
},
}}
>
{props.children}
</SettingsCenterProvider>
);
});
}
}
export default LocalizationManagementPlugin;

View File

@ -0,0 +1,7 @@
import { useTranslation } from 'react-i18next';
export const NAMESPACE = 'localization-management';
export const useLocalTranslation = () => {
return useTranslation(NAMESPACE);
};

View File

@ -0,0 +1,23 @@
export default {
Edit: '编辑',
'Add new': '新增',
'Localization management': '多语言管理',
'No data': '暂无数据',
'Delete text': '删除原文',
'Delete translation': '删除译文',
Module: '模块',
Text: '原文',
Translation: '译文',
Sync: '同步',
'Current language': '当前语言',
Keyword: '关键字',
All: '全部',
'No translation': '待翻译',
'System & Plugins': '系统和插件',
'User interfaces': '用户界面配置',
'Collections & Fields': '数据表和字段',
'Please select the resources you want to synchronize': '请选择需要同步的资源',
Menu: '菜单',
Publish: '发布',
Translations: '翻译',
};

View File

@ -0,0 +1,264 @@
import { ISchema } from '@formily/react';
const collection = {
name: 'localization',
fields: [
{
interface: 'input',
type: 'string',
name: 'module',
uiSchema: {
type: 'string',
title: '{{t("Module")}}',
'x-component': 'Select',
required: true,
enum: '{{ useModules()}}',
},
},
{
interface: 'input',
type: 'string',
name: 'text',
uiSchema: {
type: 'string',
title: '{{t("Text")}}',
'x-component': 'Input.TextArea',
required: true,
},
},
{
interface: 'input',
type: 'string',
name: 'translation',
uiSchema: {
type: 'string',
title: '{{t("Translation")}}',
'x-component': 'Input.TextArea',
},
},
],
};
export const localizationSchema: ISchema = {
type: 'void',
name: 'localization',
'x-decorator': 'ResourceActionProvider',
'x-decorator-props': {
collection,
resourceName: 'localizationTexts',
request: {
resource: 'localizationTexts',
action: 'list',
params: {
pageSize: 50,
},
},
},
'x-component': 'CollectionProvider',
'x-component-props': {
collection,
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {
currentLang: {
type: 'void',
'x-align': 'left',
'x-component': 'CurrentLang',
},
filter: {
type: 'void',
title: '{{t("Filter")}}',
'x-align': 'left',
'x-component': 'Filter',
},
deleteTranslation: {
type: 'void',
title: '{{t("Delete translation")}}',
'x-component': 'Action',
'x-component-props': {
icon: 'DeleteOutlined',
useAction: '{{ useBulkDestroyTranslationAction }}',
confirm: {
title: "{{t('Delete translation')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
sync: {
type: 'void',
title: '{{t("Sync")}}',
'x-component': 'Sync',
},
publish: {
type: 'void',
title: '{{t("Publish")}}',
'x-component': 'Action',
'x-component-props': {
icon: 'UploadOutlined',
type: 'primary',
useAction: '{{ usePublishAction }}',
},
},
},
},
table: {
type: 'void',
'x-uid': 'input',
'x-component': 'Table.Void',
'x-component-props': {
rowKey: 'translationId',
rowSelection: {
type: 'checkbox',
},
useDataSource: '{{ cm.useDataSourceFromRAC }}',
},
properties: {
// module: {
// type: 'void',
// 'x-decorator': 'Table.Column.Decorator',
// 'x-component': 'Table.Column',
// properties: {
// module: {
// type: 'string',
// 'x-component': 'CollectionField',
// 'x-read-pretty': true,
// },
// },
// },
text: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
text: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
translation: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
translation: {
type: 'string',
'x-component': 'CollectionField',
'x-component-props': {
component: 'TranslationField',
},
'x-read-pretty': true,
},
},
},
actions: {
type: 'void',
title: '{{t("Actions")}}',
'x-component': 'Table.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
update: {
type: 'void',
title: '{{t("Edit")}}',
'x-component': 'Action.Link',
'x-component-props': {
type: 'primary',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues: '{{ cm.useValuesFromRecord }}',
},
title: '{{t("Edit")}}',
properties: {
// module: {
// 'x-component': 'CollectionField',
// 'x-decorator': 'FormItem',
// 'x-read-pretty': true,
// },
text: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-read-pretty': true,
},
translation: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
required: true,
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useUpdateTranslationAction }}',
},
},
},
},
},
},
},
},
deleteTranslation: {
type: 'void',
title: '{{ t("Delete translation") }}',
'x-component': 'Action.Link',
'x-component-props': {
confirm: {
title: "{{t('Delete translation')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
useAction: '{{useDestroyTranslationAction}}',
},
'x-visible': '{{useHasTranslation()}}',
},
// deleteText: {
// type: 'void',
// title: '{{ t("Delete Text") }}',
// 'x-component': 'Action.Link',
// 'x-component-props': {
// confirm: {
// title: "{{t('Delete text')}}",
// content: "{{t('Are you sure you want to delete it?')}}",
// },
// useAction: '{{useDestroyTextAction}}',
// },
// },
},
},
},
},
},
},
},
};

View File

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

View File

@ -0,0 +1,103 @@
import Database, { Repository } from '@nocobase/database';
import { mockServer, MockServer } from '@nocobase/test';
import Plugin from '..';
describe('actions', () => {
describe('localizations', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent;
const clear = async () => {
await repo.destroy({
truncate: true,
});
await db.getRepository('localizationTranslations').destroy({
truncate: true,
});
};
beforeAll(async () => {
app = mockServer();
app.plugin(Plugin);
await app.loadAndInstall({ clean: true });
db = app.db;
repo = db.getRepository('localizationTexts');
agent = app.agent();
});
afterAll(async () => {
await db.close();
});
describe('list', () => {
beforeAll(async () => {
await repo.create({
values: [
{
module: 'test',
text: 'text',
translations: [
{
locale: 'en-US',
translation: 'translation',
},
],
},
{
module: 'test',
text: 'text1',
translations: [
{
locale: 'zh-CN',
translation: 'translation1',
},
],
},
],
});
});
afterAll(async () => {
await clear();
});
it('should list localization texts', async () => {
const res = await agent.set('X-Locale', 'en-US').resource('localizationTexts').list();
expect(res.body.data.length).toBe(2);
expect(res.body.data[0].text).toBe('text');
expect(res.body.data[0].translation).toBe('translation');
expect(res.body.data[0].translationId).toBe(1);
const res2 = await agent.set('X-Locale', 'zh-CN').resource('localizationTexts').list();
expect(res2.body.data.length).toBe(2);
expect(res2.body.data[0].text).toBe('text');
expect(res2.body.data[0].translation).toBeUndefined();
});
it('should search by keyword', async () => {
let res = await agent.set('X-Locale', 'zh-CN').resource('localizationTexts').list({
keyword: 'text',
});
expect(res.body.data.length).toBe(2);
res = await agent.set('X-Locale', 'en-US').resource('localizationTexts').list({
keyword: 'translation',
});
expect(res.body.data.length).toBe(1);
});
it('should filter no translation', async () => {
const res = await agent.set('X-Locale', 'zh-CN').resource('localizationTexts').list({
keyword: 'text',
hasTranslation: 'false',
});
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].text).toBe('text');
expect(res.body.data[0].translation).toBeUndefined();
});
});
});
});

View File

@ -0,0 +1,68 @@
import Resources from '../resources';
describe('resources', () => {
let resources: Resources;
beforeAll(() => {
resources = new Resources({
getRepository: (name: string) => {
if (name === 'localizationTexts') {
return {
find: () => [
{ id: 1, module: 'resources.client', text: 'Edit' },
{ id: 2, module: 'resources.client', text: 'Add new' },
{ id: 3, module: 'resources.acl', text: 'Admin' },
],
};
}
if (name === 'localizationTranslations') {
return {
find: () => [
{ textId: 1, translation: '编辑' },
{ textId: 3, translation: '管理员' },
],
};
}
},
} as any);
});
test('getTexts', async () => {
const texts = await resources.getTexts();
expect(texts).toBeDefined();
const cache = await resources.cache.get('localization:texts');
expect(cache).toBeDefined();
});
test('getTranslations', async () => {
const translations = await resources.getTranslations('zh-CN');
expect(translations).toBeDefined();
const cache = await resources.cache.get('localization:translations:zh-CN');
expect(cache).toBeDefined();
});
test('getResources', async () => {
const result = await resources.getResources('zh-CN');
expect(result).toEqual({
'resources.client': {
Edit: '编辑',
},
'resources.acl': {
Admin: '管理员',
},
});
});
test('filterExists', async () => {
const result = await resources.filterExists(['Edit', 'Add new', 'Admin', 'Test']);
expect(result).toEqual(['Test']);
});
test('updateCacheTexts', async () => {
const texts = [{ id: 4, module: 'resources.acl', text: 'Test' }];
await resources.updateCacheTexts(texts);
const cache = await resources.cache.get('localization:texts');
expect(cache).toBeDefined();
expect((cache as any[]).length).toBe(4);
});
});

View File

@ -0,0 +1,234 @@
import { Context, Next } from '@nocobase/actions';
import { Database, Model, Op } from '@nocobase/database';
import { getResourceLocale } from '@nocobase/plugin-client';
import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage';
import LocalizationManagementPlugin from '../plugin';
import { getTextsFromDBRecord, getTextsFromUISchema } from '../utils';
const getResourcesInstance = async (ctx: Context) => {
const plugin = ctx.app.getPlugin('localization-management') as LocalizationManagementPlugin;
return plugin.resources;
};
export const getResources = async (locale: string, db: Database) => {
const resources = await getResourceLocale(locale, db);
const client = resources['client'];
// Remove duplicated keys
Object.keys(resources).forEach((module) => {
if (module === 'client') {
return;
}
Object.keys(resources[module]).forEach((key) => {
if (client[key]) {
resources[module][key] = undefined;
}
});
});
return resources;
};
export const getUISchemas = async (db: Database) => {
const uiSchemas = await db.getModel('uiSchemas').findAll({
attributes: ['schema'],
where: {
[Op.or]: [
{
schema: {
title: {
[Op.ne]: null,
},
},
},
{
schema: {
'x-component-props': {
title: {
[Op.ne]: null,
},
},
},
},
{
schema: {
'x-decorator-props': {
title: {
[Op.ne]: null,
},
},
},
},
{
schema: {
'x-data-templates': {
[Op.ne]: null,
},
},
},
],
},
});
return uiSchemas;
};
export const resourcesToRecords = async (
locale: string,
resources: any,
): Promise<{
[key: string]: { module: string; text: string; locale: string; translation: string };
}> => {
const records = {};
for (const module in resources) {
const resource = resources[module];
for (const text in resource) {
if (resource[text] || module === 'client') {
records[text] = {
module: 'client',
text,
locale,
translation: resource[text],
};
}
}
}
return records;
};
const getTextsFromUISchemas = async (db: Database) => {
const result = {};
const schemas = await getUISchemas(db);
schemas.forEach((schema: Model) => {
const texts = getTextsFromUISchema(schema.schema);
texts.forEach((text) => (result[text] = ''));
});
return result;
};
const getTextsFromDB = async (db: Database) => {
const result = {};
const collections = Array.from(db.collections.values());
for (const collection of collections) {
const fields = Array.from(collection.fields.values())
.filter((field) => field.options?.translation)
.map((field) => field.name);
if (!fields.length) {
continue;
}
const repo = db.getRepository(collection.name);
const records = await repo.find({ fields });
records.forEach((record) => {
const texts = getTextsFromDBRecord(fields, record);
texts.forEach((text) => (result[text] = ''));
});
}
return result;
};
const getSchemaUid = async (db: Database) => {
const systemSettings = await db.getRepository('systemSettings').findOne();
const options = systemSettings?.options || {};
const { adminSchemaUid, mobileSchemaUid } = options;
return { adminSchemaUid, mobileSchemaUid };
};
const getTextsFromMenu = async (db: Database) => {
const result = {};
const { adminSchemaUid, mobileSchemaUid } = await getSchemaUid(db);
const repo = db.getRepository('uiSchemas') as UiSchemaRepository;
if (adminSchemaUid) {
const schema = await repo.getProperties(adminSchemaUid);
const extractTitle = (schema: any) => {
if (schema.properties) {
Object.values(schema.properties).forEach((item: any) => {
if (item.title) {
result[item.title] = '';
}
extractTitle(item);
});
}
};
extractTitle(schema);
}
if (mobileSchemaUid) {
const schema = await repo.getProperties(mobileSchemaUid);
if (schema['properties']?.tabBar?.properties) {
Object.values(schema['properties']?.tabBar?.properties).forEach((item: any) => {
const title = item['x-component-props']?.title;
if (title) {
result[title] = '';
}
});
}
}
return result;
};
const sync = async (ctx: Context, next: Next) => {
const startTime = Date.now();
ctx.app.logger.info('Start sync localization resources');
const resourcesInstance = await getResourcesInstance(ctx);
const locale = ctx.get('X-Locale') || 'en-US';
const { type = [] } = ctx.action.params.values || {};
if (!type.length) {
ctx.throw(400, ctx.t('Please provide synchronization source.'));
}
let resources: { [module: string]: any } = { client: {} };
if (type.includes('local')) {
resources = await getResources(locale, ctx.db);
}
if (type.includes('menu')) {
const menuTexts = await getTextsFromMenu(ctx.db);
resources['client'] = {
...menuTexts,
...resources['client'],
};
}
if (type.includes('db')) {
const dbTexts = await getTextsFromDB(ctx.db);
resources['client'] = {
...dbTexts,
...resources['client'],
};
}
const records = await resourcesToRecords(locale, resources);
let textValues = Object.values(records).map((record) => ({
module: `resources.${record.module}`,
text: record.text,
}));
textValues = (await resourcesInstance.filterExists(textValues)) as any[];
await ctx.db.sequelize.transaction(async (t) => {
const newTexts = await ctx.db.getModel('localizationTexts').bulkCreate(textValues, {
transaction: t,
});
const texts = await ctx.db.getModel('localizationTexts').findAll({
include: [{ association: 'translations', where: { locale }, required: false }],
where: { '$translations.id$': null },
transaction: t,
});
const translationValues = texts
.filter((text: Model) => records[text.text])
.map((text: Model) => {
return {
locale,
textId: text.id,
translation: records[text.text].translation,
};
})
.filter((translation) => translation.translation);
await ctx.db.getModel('localizationTranslations').bulkCreate(translationValues, {
transaction: t,
});
await resourcesInstance.updateCacheTexts(newTexts);
});
ctx.app.logger.info(`Sync localization resources done, ${Date.now() - startTime}ms`);
await next();
};
const publish = async (ctx: Context, next: Next) => {
const resources = await getResourcesInstance(ctx);
ctx.body = await resources.resetCache(ctx.get('X-Locale') || 'en-US');
await next();
};
export default { publish, sync };

View File

@ -0,0 +1,88 @@
import { Context, Next } from '@nocobase/actions';
import { Database, Model, Op } from '@nocobase/database';
import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '../constans';
const appendTranslations = async (db: Database, rows: Model[], locale: string): Promise<any[]> => {
const texts = rows || [];
const textIds = texts.map((text: any) => text.id);
const textMp = texts.reduce((memo: any, text: any) => {
memo[text.id] = text;
return memo;
}, {});
const repo = db.getRepository('localizationTranslations');
const translations = await repo.find({
filter: {
locale,
textId: textIds,
},
});
translations.forEach((translation: Model) => {
const text = textMp[translation.textId];
if (text) {
text.set('translation', translation.translation, { raw: true });
text.set('translationId', translation.id, { raw: true });
textMp[translation.textId] = text;
}
});
return Object.values(textMp);
};
const listText = async (db: Database, params: any): Promise<[any[], number]> => {
const { keyword, hasTranslation, locale, options } = params;
if (keyword || !hasTranslation) {
options['include'] = [{ association: 'translations', where: { locale }, required: false }];
if (!hasTranslation) {
if (keyword) {
options['where'] = {
[Op.and]: [
{ text: { [Op.like]: `%${keyword}%` } },
{
'$translations.id$': null,
},
],
};
} else {
options['where'] = {
'$translations.id$': null,
};
}
} else {
options['where'] = {
[Op.or]: [
{ text: { [Op.like]: `%${keyword}%` } },
{ '$translations.translation$': { [Op.like]: `%${keyword}%` } },
],
};
}
}
const [rows, count] = await db.getRepository('localizationTexts').findAndCount(options);
if (!hasTranslation) {
return [rows, count];
}
return [await appendTranslations(db, rows, locale), count];
};
const list = async (ctx: Context, next: Next) => {
const locale = ctx.get('X-Locale') || 'en-US';
let { page = DEFAULT_PAGE, pageSize = DEFAULT_PER_PAGE, hasTranslation } = ctx.action.params;
page = parseInt(String(page));
pageSize = parseInt(String(pageSize));
hasTranslation = hasTranslation === 'true' || hasTranslation === undefined;
const { keyword } = ctx.action.params;
const options = {
context: ctx,
offset: (page - 1) * pageSize,
limit: pageSize,
};
const [rows, count] = await listText(ctx.db, { keyword, hasTranslation, locale, options });
ctx.body = {
count,
rows,
page,
pageSize,
totalPage: Math.ceil(count / pageSize),
};
await next();
};
export default { list };

View File

@ -0,0 +1,64 @@
import { CollectionOptions } from '@nocobase/client';
export default {
namespace: 'localization.localization',
duplicator: 'optional',
name: 'localizationTexts',
title: '{{t("Localization Texts")}}',
model: 'LocalizationTextModel',
createdBy: true,
updatedBy: true,
logging: true,
fields: [
{
name: 'id',
type: 'bigInt',
autoIncrement: true,
primaryKey: true,
allowNull: false,
interface: 'id',
},
{
interface: 'input',
type: 'string',
name: 'module',
allowNull: false,
uiSchema: {
type: 'string',
title: '{{t("Module")}}',
'x-component': 'Select',
required: true,
},
},
{
interface: 'input',
type: 'text',
name: 'text',
allowNull: false,
uiSchema: {
type: 'string',
title: '{{t("Text")}}',
'x-component': 'Input',
required: true,
},
},
{
name: 'batch',
type: 'string',
},
{
interface: 'o2m',
type: 'hasMany',
name: 'translations',
target: 'localizationTranslations',
sourceKey: 'id',
foreignKey: 'textId',
onDelete: 'CASCADE',
},
],
indexes: [
{
fields: ['batch'],
},
],
} as CollectionOptions;

View File

@ -0,0 +1,61 @@
import { CollectionOptions } from '@nocobase/client';
export default {
namespace: 'localization.localization',
duplicator: 'optional',
name: 'localizationTranslations',
title: '{{t("Localization Translations")}}',
model: 'LocalizationTranslationModel',
createdBy: true,
updatedBy: true,
logging: true,
fields: [
{
name: 'id',
type: 'bigInt',
autoIncrement: true,
primaryKey: true,
allowNull: false,
interface: 'id',
},
{
interface: 'input',
type: 'string',
name: 'locale',
allowNull: false,
uiSchema: {
type: 'string',
title: '{{t("Locale")}}',
'x-component': 'Input',
required: true,
},
},
{
interface: 'input',
type: 'text',
name: 'translation',
allowNull: false,
defaultValue: '',
uiSchema: {
type: 'string',
title: '{{t("Translation")}}',
'x-component': 'Input',
required: true,
},
},
{
interface: 'm2o',
type: 'belongsTo',
name: 'text',
target: 'localizationTexts',
targetKey: 'id',
foreignKey: 'textId',
},
],
indexes: [
{
fields: ['locale', 'textId'],
unique: true,
},
],
} as CollectionOptions;

View File

@ -0,0 +1,4 @@
export const DEFAULT_PAGE = 1;
export const DEFAULT_PER_PAGE = 20;
export const CACHE_KEY = 'localization:texts';

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,28 @@
import { Migration } from '@nocobase/server';
export default class AddTranslationToRoleTitleMigration extends Migration {
async up() {
const repo = this.context.db.getRepository('fields');
const field = await repo.findOne({
where: {
collectionName: 'roles',
name: 'title',
},
});
if (field) {
await repo.update({
filter: {
key: field.key,
},
values: {
options: {
...field.options,
translation: true,
},
},
});
}
}
async down() {}
}

View File

@ -0,0 +1,158 @@
import { Model } from '@nocobase/database';
import { UiSchemaStoragePlugin } from '@nocobase/plugin-ui-schema-storage';
import { InstallOptions, Plugin } from '@nocobase/server';
import deepmerge from 'deepmerge';
import { resolve } from 'path';
import localization from './actions/localization';
import localizationTexts from './actions/localizationTexts';
import Resources from './resources';
import { getTextsFromDBRecord } from './utils';
export class LocalizationManagementPlugin extends Plugin {
resources: Resources;
registerUISchemahook(plugin?: UiSchemaStoragePlugin) {
const uiSchemaStoragePlugin = plugin || this.app.getPlugin<UiSchemaStoragePlugin>('ui-schema-storage');
if (!uiSchemaStoragePlugin) {
return;
}
uiSchemaStoragePlugin.serverHooks.register('onSelfSave', 'extractTextToLocale', async ({ schemaInstance }) => {
const schema = schemaInstance.get('schema');
const title = schema?.title || schema?.['x-component-props']?.title;
if (!title) {
return;
}
const result = await this.resources.filterExists([title]);
if (!result.length) {
return;
}
this.db
.getRepository('localizationTexts')
.create({
values: {
module: 'resources.client',
text: title,
},
})
.then((res) => this.resources.updateCacheTexts([res]))
.catch((err) => {});
});
}
afterAdd() {}
beforeLoad() {}
async load() {
await this.db.import({
directory: resolve(__dirname, 'collections'),
});
this.db.addMigrations({
namespace: 'localization-management',
directory: resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
this.app.resource({
name: 'localizationTexts',
actions: localizationTexts,
});
this.app.resource({
name: 'localization',
actions: localization,
});
this.app.acl.registerSnippet({
name: `pm.${this.name}.localization`,
actions: ['localization:*', 'localizationTexts:*'],
});
this.db.on('afterSave', async (instance: Model) => {
if (!this.enabled) {
return;
}
const model = instance.constructor as typeof Model;
const collection = model.collection;
let texts = [];
const fields = Array.from(collection.fields.values())
.filter((field) => field.options?.translation && instance['_changed'].has(field.name))
.map((field) => field.name);
if (!fields.length) {
return;
}
const textsFromDB = getTextsFromDBRecord(fields, instance);
textsFromDB.forEach((text) => {
texts.push(text);
});
texts = await this.resources.filterExists(texts);
this.db
.getModel('localizationTexts')
.bulkCreate(texts.map((text) => ({ module: 'resources.client', text })))
.then((newTexts) => this.resources.updateCacheTexts(newTexts))
.catch((err) => {});
});
this.resources = new Resources(this.db);
// ui-schema-storage loaded before localization-management
this.registerUISchemahook();
this.app.on('afterLoadPlugin', async (plugin) => {
if (plugin.name === 'ui-schema-storage') {
// ui-schema-storage loaded after localization-management
this.registerUISchemahook(plugin);
}
});
this.app.resourcer.use(async (ctx, next) => {
await next();
const { resourceName, actionName } = ctx.action.params;
if (resourceName === 'app' && actionName === 'getLang') {
const custom = await this.resources.getResources(ctx.get('X-Locale') || 'en-US');
const appLang = ctx.body;
const resources = {};
Object.keys(appLang.resources).forEach((key) => {
const resource = custom[`resources.${key}`];
resources[key] = resource ? deepmerge(appLang.resources[key], resource) : { ...appLang.resources[key] };
});
// For duplicate texts, use translations from client to override translations in other modules
const client = resources['client'] || {};
Object.keys(resources).forEach((key) => {
if (key === 'client') {
return;
}
Object.keys(resources[key]).forEach((text) => {
if (client[text]) {
resources[key][text] = client[text];
}
});
});
ctx.body = {
...appLang,
resources,
};
}
});
}
async install(options?: InstallOptions) {}
async afterEnable() {}
async afterDisable() {
const uiSchemaStoragePlugin = this.app.getPlugin<UiSchemaStoragePlugin>('ui-schema-storage');
if (!uiSchemaStoragePlugin) {
return;
}
uiSchemaStoragePlugin.serverHooks.remove('onSelfSave', 'extractTextToLocale');
}
async remove() {}
}
export default LocalizationManagementPlugin;

View File

@ -0,0 +1,78 @@
import { Cache, createCache } from '@nocobase/cache';
import { Database } from '@nocobase/database';
export default class Resources {
cache: Cache;
db: Database;
CACHE_KEY_PREFIX = 'localization:';
constructor(db: Database) {
this.cache = createCache();
this.db = db;
}
async getTexts() {
return await this.cache.wrap(`${this.CACHE_KEY_PREFIX}texts`, async () => {
return await this.db.getRepository('localizationTexts').find({
fields: ['id', 'module', 'text'],
raw: true,
});
});
}
async getTranslations(locale: string) {
return await this.cache.wrap(`${this.CACHE_KEY_PREFIX}translations:${locale}`, async () => {
return await this.db.getRepository('localizationTranslations').find({
fields: ['textId', 'translation'],
filter: { locale },
raw: true,
});
});
}
async getResources(locale: string) {
const [texts, translations] = await Promise.all([this.getTexts(), this.getTranslations(locale)]);
const resources = {};
const textsMap = texts.reduce((map, item) => {
map[item.id] = item;
return map;
}, {});
translations.forEach((item) => {
const text = textsMap[item.textId];
if (!text) {
return;
}
const module = text.module;
if (!resources[module]) {
resources[module] = {};
}
resources[module][text.text] = item.translation;
});
return resources;
}
async filterExists(texts: (string | { text: string })[]) {
let existTexts = await this.getTexts();
existTexts = existTexts.map((item) => item.text);
return texts.filter((text) => {
if (typeof text === 'string') {
return !existTexts.includes(text);
}
return !existTexts.includes(text.text);
});
}
async updateCacheTexts(texts: any[]) {
const newTexts = texts.map((text) => ({
id: text.id,
module: text.module,
text: text.text,
}));
const existTexts = await this.getTexts();
await this.cache.set(`${this.CACHE_KEY_PREFIX}texts`, [...existTexts, ...newTexts]);
}
async resetCache(locale: string) {
await this.cache.del(`${this.CACHE_KEY_PREFIX}translations:${locale}`);
}
}

View File

@ -0,0 +1,49 @@
export const compile = (title: string) => (title || '').replace(/{{\s*t\(["|'|`](.*)["|'|`]\)\s*}}/g, '$1');
export const getTextsFromUISchema = (schema: any) => {
const texts = [];
const title = compile(schema.title);
const componentPropsTitle = compile(schema['x-component-props']?.title);
const decoratorPropsTitle = compile(schema['x-decorator-props']?.title);
if (title) {
texts.push(title);
}
if (componentPropsTitle) {
texts.push(componentPropsTitle);
}
if (decoratorPropsTitle) {
texts.push(decoratorPropsTitle);
}
if (schema['x-data-templates']?.items?.length) {
schema['x-data-templates'].items.forEach((item: any) => {
const title = compile(item.title);
if (title) {
texts.push(title);
}
});
}
return texts;
};
export const getTextsFromDBRecord = (fields: string[], record: any) => {
const texts = [];
fields.forEach((field) => {
const value = record[field];
if (typeof value === 'string') {
texts.push(compile(value));
}
if (typeof value === 'object') {
if (value?.uiSchema?.title) {
texts.push(compile(value.uiSchema.title));
}
if (value?.uiSchema?.enum) {
value.uiSchema.enum.forEach((item) => {
if (item?.label) {
texts.push(compile(item.label));
}
});
}
}
});
return texts;
};

View File

@ -1,12 +1,10 @@
import { i18n } from '@nocobase/client';
import { useTranslation } from 'react-i18next';
import enUS from './en-US';
import zhCN from './zh-CN';
export const NAMESPACE = 'map';
i18n.addResources('zh-CN', NAMESPACE, zhCN);
i18n.addResources('en-US', NAMESPACE, enUS);
// i18n.addResources('zh-CN', NAMESPACE, zhCN);
// i18n.addResources('en-US', NAMESPACE, enUS);
export function lang(key: string) {
return i18n.t(key, { ns: NAMESPACE });

View File

@ -1,4 +1,5 @@
const locale = {
Map: '地图',
'Map-based geometry': '基于地图的几何图形',
'Map type': '地图类型',
Point: '点',
@ -45,6 +46,7 @@ const locale = {
'Marker field': '标记字段',
'Load google maps failed, Please check the Api key and refresh the page':
'加载谷歌地图失败,请检查 Api key 并刷新页面',
'Create map block': '创建地图区块',
};
export default locale;

View File

@ -1,11 +1,11 @@
import { useField, useFieldSchema } from '@formily/react';
import {
css,
cx,
GeneralSchemaDesigner,
Icon,
SchemaSettings,
SortableItem,
css,
cx,
useCompile,
useDesigner,
} from '@nocobase/client';

View File

@ -37,6 +37,12 @@ export const InternalTabBar: React.FC = (props) => {
properties: {
[uid()]: PageSchema,
},
'x-server-hooks': [
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
],
});
}, []);
@ -76,7 +82,7 @@ export const InternalTabBar: React.FC = (props) => {
key={`tab_${schema['x-uid']}`}
title={
<>
{compile(cp.title)}
{t(compile(cp.title))}
<SchemaComponent schema={schema} name={name} />
</>
}

View File

@ -15,6 +15,12 @@ export const useSchemaPatch = () => {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-component-props': fieldSchema['x-component-props'],
'x-server-hooks': [
{
type: 'onSelfSave',
method: 'extractTextToLocale',
},
],
},
});
dn.refresh();

View File

@ -1,12 +1,10 @@
import { i18n } from '@nocobase/client';
import { useTranslation as useT } from 'react-i18next';
import enUS from './en-US';
import zhCN from './zh-CN';
export const NAMESPACE = 'mobile-client';
i18n.addResources('zh-CN', NAMESPACE, zhCN);
i18n.addResources('en-US', NAMESPACE, enUS);
// i18n.addResources('zh-CN', NAMESPACE, zhCN);
// i18n.addResources('en-US', NAMESPACE, enUS);
export function lang(key: string) {
return i18n.t(key, { ns: NAMESPACE });

View File

@ -21,6 +21,7 @@ class SubAppPlugin extends Plugin {
'sequence-field',
'snapshot-field',
'verification',
'localization-management',
];
const collectionGroups = mainApp.db.collectionGroupManager.getGroups();

View File

@ -3,6 +3,7 @@ import { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client'
import { useMemoizedFn } from 'ahooks';
import { Button, Space } from 'antd';
import React, { useEffect, useState } from 'react';
import { useOidcTranslation } from './locale';
export interface OIDCProvider {
clientId: string;
@ -10,6 +11,7 @@ export interface OIDCProvider {
}
export const OIDCButton = (props: { authenticator: Authenticator }) => {
const { t } = useOidcTranslation();
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
const api = useAPIClient();
const redirect = useRedirect();
@ -77,7 +79,7 @@ export const OIDCButton = (props: { authenticator: Authenticator }) => {
`}
>
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
{authenticator.title}
{t(authenticator.title)}
</Button>
</Space>
);

View File

@ -2,8 +2,10 @@ import { LoginOutlined } from '@ant-design/icons';
import { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client';
import { Button, Space } from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { useSamlTranslation } from './locale';
export const SAMLButton = (props: { authenticator: Authenticator }) => {
const { t } = useSamlTranslation();
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
const api = useAPIClient();
const redirect = useRedirect();
@ -70,7 +72,7 @@ export const SAMLButton = (props: { authenticator: Authenticator }) => {
`}
>
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
{authenticator.title}
{t(authenticator.title)}
</Button>
</Space>
);

View File

@ -8,6 +8,7 @@ export default defineCollection({
{
type: 'string',
name: 'title',
translation: true,
},
{
type: 'boolean',

View File

@ -14,6 +14,7 @@ export default defineCollection({
{
type: 'string',
name: 'name',
translation: true,
},
{
type: 'string',

View File

@ -341,6 +341,9 @@ export class UiSchemaRepository extends Repository {
s.set('schema', { ...s.toJSON(), ...newSchema });
// console.log(s.toJSON());
await s.save({ transaction, hooks: false });
if (newSchema['x-server-hooks']) {
await this.database.emitAsync(`${this.collection.name}.afterSave`, s, options);
}
return;
}
const oldTree = await this.getJsonSchema(rootUid, { transaction });
@ -389,6 +392,10 @@ export class UiSchemaRepository extends Repository {
transaction,
},
);
if (schema['x-server-hooks']) {
await this.database.emitAsync(`${this.collection.name}.afterSave`, nodeModel, { transaction });
}
}
protected async childrenCount(uid, transaction) {
@ -806,6 +813,7 @@ export class UiSchemaRepository extends Repository {
if (rootNode['x-server-hooks']) {
const rootModel = await this.findOne({ filter: { 'x-uid': rootNode['x-uid'] }, transaction });
await this.database.emitAsync(`${this.collection.name}.afterCreateWithAssociations`, rootModel, options);
await this.database.emitAsync(`${this.collection.name}.afterSave`, rootModel, options);
}
if (options?.returnNode) {

View File

@ -8,6 +8,7 @@ export type HookType =
| 'onCollectionFieldDestroy'
| 'onAnyCollectionFieldDestroy'
| 'onSelfCreate'
| 'onSelfSave'
| 'onSelfMove';
export class ServerHooks {
@ -39,6 +40,10 @@ export class ServerHooks {
this.db.on('uiSchemaMove', async (model, options) => {
await this.onUiSchemaMove(model, options);
});
this.db.on('uiSchemas.afterSave', async (model, options) => {
await this.onUiSchemaSave(model, options);
});
}
protected async callSchemaInstanceHooksByType(schemaInstance, options, type: HookType) {
@ -48,7 +53,7 @@ export class ServerHooks {
for (const hook of hooks) {
const hookFunc = this.hooks.get(type)?.get(hook['method']);
await hookFunc({
await hookFunc?.({
schemaInstance,
options,
db: this.db,
@ -117,6 +122,10 @@ export class ServerHooks {
await this.callSchemaInstanceHooksByType(schemaInstance, options, 'onSelfCreate');
}
protected async onUiSchemaSave(schemaInstance, options) {
await this.callSchemaInstanceHooksByType(schemaInstance, options, 'onSelfSave');
}
protected async findHooksAndCall(hooksFilter, hooksArgs, transaction) {
const hooks = (await this.db.getRepository('uiSchemaServerHooks').find({
filter: hooksFilter,
@ -153,4 +162,13 @@ export class ServerHooks {
const hookTypeMap = this.hooks.get(type);
hookTypeMap.set(name, hookFunc);
}
remove(type: HookType, name: string) {
if (!this.hooks.has(type)) {
return;
}
const hookTypeMap = this.hooks.get(type);
hookTypeMap.delete(name);
}
}

View File

@ -22,6 +22,7 @@
"@nocobase/plugin-graph-collection-manager": "0.11.0-alpha.1",
"@nocobase/plugin-iframe-block": "0.11.0-alpha.1",
"@nocobase/plugin-import": "0.11.0-alpha.1",
"@nocobase/plugin-localization-management": "0.11.0-alpha.1",
"@nocobase/plugin-map": "0.11.0-alpha.1",
"@nocobase/plugin-math-formula-field": "0.11.0-alpha.1",
"@nocobase/plugin-mobile-client": "0.11.0-alpha.1",

View File

@ -40,6 +40,7 @@ export class PresetNocoBase extends Plugin {
'graph-collection-manager',
'mobile-client',
'api-keys',
'localization-management',
'theme-editor',
];

3696
yarn.lock

File diff suppressed because it is too large Load Diff