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:
parent
4e84b14bc7
commit
70d5b9e44b
@ -0,0 +1 @@
|
||||
export { default } from '@nocobase/plugin-localization-management/client';
|
@ -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) {
|
||||
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
) : (
|
||||
|
@ -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}
|
||||
|
@ -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>,
|
||||
},
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
@ -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,
|
||||
}));
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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';
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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]);
|
||||
|
@ -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 && (
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
@ -56,6 +56,7 @@ export default {
|
||||
title: '{{t("Title")}}',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
translation: true,
|
||||
},
|
||||
{
|
||||
interface: 'textarea',
|
||||
|
@ -1 +1 @@
|
||||
export { default } from './server';
|
||||
export { default, getResourceLocale } from './server';
|
||||
|
@ -1 +1,2 @@
|
||||
export { getResourceLocale } from './resource';
|
||||
export { default } from './server';
|
||||
|
@ -13,6 +13,7 @@ export default {
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
translation: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
|
@ -27,6 +27,7 @@ export default {
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
required: true,
|
||||
translation: true,
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
|
@ -63,6 +63,7 @@ export default {
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
defaultValue: {},
|
||||
translation: true,
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
||||
|
@ -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: {
|
||||
|
@ -14,7 +14,7 @@ const Chart: React.FC = (props) => {
|
||||
children.push({
|
||||
key: 'chart-v2',
|
||||
type: 'item',
|
||||
title: t('Chart'),
|
||||
title: t('Charts'),
|
||||
component: 'ChartV2BlockInitializer',
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -68,4 +68,5 @@ export default {
|
||||
Min: '最小值',
|
||||
Max: '最大值',
|
||||
'Please select a chart type.': '请选择图表类型',
|
||||
Collection: '数据表',
|
||||
};
|
||||
|
@ -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 [
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
@ -11,6 +11,7 @@ export default {
|
||||
comment: '存储引擎名称',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
translation: true,
|
||||
},
|
||||
{
|
||||
title: '英文标识',
|
||||
|
35
packages/plugins/localization-management/README.md
Normal file
35
packages/plugins/localization-management/README.md
Normal 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" />
|
||||
|
||||
|
||||
|
3
packages/plugins/localization-management/client.d.ts
vendored
Executable file
3
packages/plugins/localization-management/client.d.ts
vendored
Executable file
@ -0,0 +1,3 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/client';
|
||||
export { default } from './lib/client';
|
65
packages/plugins/localization-management/client.js
Executable file
65
packages/plugins/localization-management/client.js
Executable 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];
|
||||
},
|
||||
});
|
||||
});
|
21
packages/plugins/localization-management/package.json
Normal file
21
packages/plugins/localization-management/package.json
Normal 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": "支持管理应用程序的多语言资源。"
|
||||
}
|
3
packages/plugins/localization-management/server.d.ts
vendored
Executable file
3
packages/plugins/localization-management/server.d.ts
vendored
Executable file
@ -0,0 +1,3 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/server';
|
||||
export { default } from './lib/server';
|
65
packages/plugins/localization-management/server.js
Executable file
65
packages/plugins/localization-management/server.js
Executable 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];
|
||||
},
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -0,0 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const NAMESPACE = 'localization-management';
|
||||
|
||||
export const useLocalTranslation = () => {
|
||||
return useTranslation(NAMESPACE);
|
||||
};
|
@ -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: '翻译',
|
||||
};
|
@ -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}}',
|
||||
// },
|
||||
// },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
1
packages/plugins/localization-management/src/index.ts
Normal file
1
packages/plugins/localization-management/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './server';
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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 };
|
@ -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 };
|
@ -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;
|
@ -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;
|
@ -0,0 +1,4 @@
|
||||
export const DEFAULT_PAGE = 1;
|
||||
export const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
export const CACHE_KEY = 'localization:texts';
|
@ -0,0 +1 @@
|
||||
export { default } from './plugin';
|
@ -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() {}
|
||||
}
|
158
packages/plugins/localization-management/src/server/plugin.ts
Normal file
158
packages/plugins/localization-management/src/server/plugin.ts
Normal 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;
|
@ -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}`);
|
||||
}
|
||||
}
|
49
packages/plugins/localization-management/src/server/utils.ts
Normal file
49
packages/plugins/localization-management/src/server/utils.ts
Normal 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;
|
||||
};
|
@ -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 });
|
||||
|
@ -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;
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { useField, useFieldSchema } from '@formily/react';
|
||||
import {
|
||||
css,
|
||||
cx,
|
||||
GeneralSchemaDesigner,
|
||||
Icon,
|
||||
SchemaSettings,
|
||||
SortableItem,
|
||||
css,
|
||||
cx,
|
||||
useCompile,
|
||||
useDesigner,
|
||||
} from '@nocobase/client';
|
||||
|
@ -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} />
|
||||
</>
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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 });
|
||||
|
@ -21,6 +21,7 @@ class SubAppPlugin extends Plugin {
|
||||
'sequence-field',
|
||||
'snapshot-field',
|
||||
'verification',
|
||||
'localization-management',
|
||||
];
|
||||
|
||||
const collectionGroups = mainApp.db.collectionGroupManager.getGroups();
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -8,6 +8,7 @@ export default defineCollection({
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
translation: true,
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
|
@ -14,6 +14,7 @@ export default defineCollection({
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
translation: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -40,6 +40,7 @@ export class PresetNocoBase extends Plugin {
|
||||
'graph-collection-manager',
|
||||
'mobile-client',
|
||||
'api-keys',
|
||||
'localization-management',
|
||||
'theme-editor',
|
||||
];
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user