From b9ce35d62168298f0d21279877b795c97c52b72b Mon Sep 17 00:00:00 2001 From: Junyi Date: Thu, 29 Sep 2022 21:04:58 +0800 Subject: [PATCH] Doc: dev i18n (#858) * docs: add guide index table and i18n * docs: add dev i18n sample --- docs/menus.ts | 12 +- docs/zh-CN/api/server/application.md | 5 + docs/zh-CN/development/guide/i18n.md | 234 +++++++++++++++++- docs/zh-CN/development/guide/index.md | 77 +++++- docs/zh-CN/development/index.md | 21 +- packages/samples/shop-i18n/README.md | 49 ++++ packages/samples/shop-i18n/client.d.ts | 4 + packages/samples/shop-i18n/client.js | 30 +++ packages/samples/shop-i18n/package.json | 18 ++ packages/samples/shop-i18n/server.d.ts | 4 + packages/samples/shop-i18n/server.js | 30 +++ .../samples/shop-i18n/src/client/index.tsx | 5 + packages/samples/shop-i18n/src/index.ts | 1 + .../shop-i18n/src/server/actions/.gitkeep | 0 .../shop-i18n/src/server/collections/.gitkeep | 0 .../src/server/collections/categories.ts | 13 + .../src/server/collections/orders.ts | 25 ++ .../src/server/collections/products.ts | 21 ++ .../samples/shop-i18n/src/server/index.ts | 65 +++++ .../shop-i18n/src/server/models/.gitkeep | 0 .../src/server/repositories/.gitkeep | 0 21 files changed, 591 insertions(+), 23 deletions(-) create mode 100644 packages/samples/shop-i18n/README.md create mode 100755 packages/samples/shop-i18n/client.d.ts create mode 100755 packages/samples/shop-i18n/client.js create mode 100644 packages/samples/shop-i18n/package.json create mode 100755 packages/samples/shop-i18n/server.d.ts create mode 100755 packages/samples/shop-i18n/server.js create mode 100644 packages/samples/shop-i18n/src/client/index.tsx create mode 100644 packages/samples/shop-i18n/src/index.ts create mode 100644 packages/samples/shop-i18n/src/server/actions/.gitkeep create mode 100644 packages/samples/shop-i18n/src/server/collections/.gitkeep create mode 100644 packages/samples/shop-i18n/src/server/collections/categories.ts create mode 100644 packages/samples/shop-i18n/src/server/collections/orders.ts create mode 100644 packages/samples/shop-i18n/src/server/collections/products.ts create mode 100644 packages/samples/shop-i18n/src/server/index.ts create mode 100644 packages/samples/shop-i18n/src/server/models/.gitkeep create mode 100644 packages/samples/shop-i18n/src/server/repositories/.gitkeep diff --git a/docs/menus.ts b/docs/menus.ts index daa28b1b7..db0329f8b 100644 --- a/docs/menus.ts +++ b/docs/menus.ts @@ -185,8 +185,8 @@ export default { ], }, { - title: '@nocobase/acl', - path: '/api/acl', + title: '@nocobase/actions', + path: '/api/actions', }, { title: '@nocobase/client', @@ -219,12 +219,12 @@ export default { ], }, { - title: '@nocobase/cli', - path: '/api/cli', + title: '@nocobase/acl', + path: '/api/acl', }, { - title: '@nocobase/actions', - path: '/api/actions', + title: '@nocobase/cli', + path: '/api/cli', }, { title: '@nocobase/sdk', diff --git a/docs/zh-CN/api/server/application.md b/docs/zh-CN/api/server/application.md index a9e1224b5..4c94b556f 100644 --- a/docs/zh-CN/api/server/application.md +++ b/docs/zh-CN/api/server/application.md @@ -57,6 +57,10 @@ const app = new Application({ ## 实例成员 +### `cli` + +命令行工具实例,参考 npm 包 [Commander](https://www.npmjs.com/package/commander)。 + ### `db` 数据库实例,相关 API 参考 [Database](/api/database)。 @@ -105,6 +109,7 @@ NocoBase 默认对 context 注入了以下成员,可以在请求处理函数 | `ctx.resourcer` | `Resourcer` | 资源路由管理器实例 | | `ctx.action` | `Action` | 资源操作相关对象实例 | | `ctx.i18n` | `I18n` | 国际化实例 | +| `ctx.t` | `i18n.t` | 国际化翻译函数快捷方式 | | `ctx.getBearerToken()` | `Function` | 获取请求头中的 bearer token | ## 实例方法 diff --git a/docs/zh-CN/development/guide/i18n.md b/docs/zh-CN/development/guide/i18n.md index a4f29d977..bb742ac5b 100644 --- a/docs/zh-CN/development/guide/i18n.md +++ b/docs/zh-CN/development/guide/i18n.md @@ -1 +1,233 @@ -# I18n \ No newline at end of file +# 国际化 + +## 基础概念 + +多语言国际化支持根据服务端和客户端分为两大部分,各自有相应的实现。 + +语言配置均为普通的 JSON 对象键值对,如果没有翻译文件(或省略编写)的话会直接输出键名的字符串。 + +### 服务端 + +服务端的多语言国际化基于 npm 包 [i18next](https://npmjs.com/package/i18next) 实现,在服务端应用初始化时会创建一个 i18next 实例,同时也会将此实例注入到请求上下文(`context`)中,以供在各处方便的使用。 + +在创建服务端 Application 实例时可以传入配置对应的初始化参数: + +```ts +import { Application } from '@nocobase/server'; + +const app = new Application({ + i18n: { + defaultNS: 'test', + resources: { + 'en-US': { + test: { + hello: 'Hello', + }, + }, + 'zh-CN': { + test: { + hello: '你好', + } + } + } + } +}) +``` + +或者在插件中对已存在的 app 实例添加语言数据至对应命名空间: + +```ts +app.i18n.addResources('zh-CN', 'test', { + Hello: '你好', + World: '世界', +}); + +app.i18n.addResources('en-US', 'test', { + Hello: 'Hello', + World: 'World', +}); +``` + +基于应用: + +```ts +app.i18n.t('World') // “世界”或“World” +``` + +基于请求: + +```ts +app.resource({ + name: 'test', + actions: { + async get(ctx, next) { + ctx.body = `${ctx.t('Hello')} ${ctx.t('World')}`; + await next(); + } + } +}); +``` + +通常服务端的多语言处理主要用于错误信息的输出。 + +### 客户端 + +客户端的多语言国际化基于 npm 包 [react-i18next](https://npmjs.com/package/react-i18next) 实现,在应用顶层提供了 `` 组件的包装,可以在任意位置直接使用相关的方法。 + +在组件中调用翻译函数: + +```tsx | pure +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function MyComponent() { + const { t } = useTranslation(); + + return ( +
+

{t('World')}

+
+ ); +} +``` + +其中 `compile()` 方法是专门针对 SchemaComponent 中纯 JSON 配置提供的编译方法,执行后也可以得到对应的翻译结果。 + +在 Schema 组件中使用: + +```tsx +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SchemaComponent } from '@nocobase/client'; + +export default function MySchemaComponent() { + const { t } = useTranslation(); + return ( + + ); +} +``` + +在 SchemaComponent 组件中使用时,可以在 schema 配置里直接使用 JSON 的可编译字符串模板表达要翻译的语言片段键名。 + +## 示例 + +### 服务端错误提示 + +例如用户在店铺对某个商品下单时,如果商品的库存不够,或者未上架,那么下单接口被调用时,应该返回相应的错误。 + +```ts +const namespace = 'shop'; + +export default class ShopPlugin extends Plugin { + async load() { + this.app.i18n.addResources('zh-CN', namespace, { + 'No such product': '商品不存在', + 'Product not on sale': '商品已下架', + 'Out of stock': '库存不足', + }); + + this.app.resource({ + name: 'orders', + actions: { + async create(ctx, next) { + const productRepo = ctx.db.getRepository('products'); + const product = await productRepo.findOne({ + filterByTk: ctx.action.params.filterByTk + }); + + if (!product) { + return ctx.throw(404, ctx.t('No such product')); + } + + if (!product.enabled) { + return ctx.throw(400, ctx.t('Product not on sale')); + } + + if (!product.inventory) { + return ctx.throw(400, ctx.t('Out of stock')); + } + + const orderRepo = ctx.db.getRepository('orders'); + ctx.body = await orderRepo.create({ + values: { + productId: product.id, + quantity: 1, + totalPrice: product.price, + userId: ctx.state.currentUser.id + } + }); + + next(); + } + } + }); + } +} +``` + +### 客户端组件多语言 + +例如订单状态的组件,根据不同值有不同的文本显示: + +```tsx +import React from 'react'; +import { Select } from 'antd'; +import i18next from 'i18next'; +import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next'; + +const i18n = i18next.createInstance(); +i18n.use(initReactI18next).init({ + lng: 'zh-CN', + defaultNS: 'client', + resources: { + 'zh-CN': { + client: { + Pending: '已下单', + Paid: '已支付', + Delivered: '已发货', + Received: '已签收' + } + } + } +}); + +const ORDER_STATUS_LIST = [ + { value: -1, label: 'Canceled (untranslated)' }, + { value: 0, label: 'Pending' }, + { value: 1, label: 'Paid' }, + { value: 2, label: 'Delivered' }, + { value: 3, label: 'Received' }, +] + +function OrderStatusSelect() { + const { t } = useTranslation(); + + return ( + + ); +} + +export default function () { + return ( + + + + ); +} +``` diff --git a/docs/zh-CN/development/guide/index.md b/docs/zh-CN/development/guide/index.md index 07dd0c5c7..f8ef6f114 100644 --- a/docs/zh-CN/development/guide/index.md +++ b/docs/zh-CN/development/guide/index.md @@ -1 +1,76 @@ -# Overview +# 扩展点索引 + +## 内核模块 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
扩展点相关介绍相关 API
扩展数据库字段类型数据表与字段db.registerFieldType()
扩展已有数据库表的字段db.extendcollection()
扩展查询比较运算符db.registerOperators()
自定义数据仓库操作db.registerRepositories()
扩展针对全局资源的操作资源与操作resourcer.registerActions()
扩展针对全局资源的中间件resourcer.use()
自定义资源及操作resourcer.define()
自定义路由与页面界面路由RouteSwitch
扩展自定义组件扩展 Schema 组件SchemaComponent +
扩展自定义区块类型Schema 设计能力SchemaInitializer
多语言扩展国际化app.i18n
扩展子命令命令行工具app.i18n
+ +## 内置插件 + +TODO diff --git a/docs/zh-CN/development/index.md b/docs/zh-CN/development/index.md index 0f5e2c474..2faf5286d 100644 --- a/docs/zh-CN/development/index.md +++ b/docs/zh-CN/development/index.md @@ -2,25 +2,13 @@ ## 基本概念 -NocoBase 的核心是一个基于微内核、插件化设计的开发框架。在此基础上天然的支持任意的扩展开发。 - ## 特性 +NocoBase 的核心是一个基于微内核、插件化设计的开发框架。在此基础上天然的支持任意的扩展开发。 + ## 扩展能力 -基于 NocoBase 的微内核设计,在很多模块都考虑相应合理的可扩展性,并向外部暴露了一些扩展点。例如: - -* 数据库字段类型 -* 已有数据库表的字段 -* 通用操作和对特定的资源的操作 -* 针对资源和操作的预处理和后处理 -* 字段的前端展示组件 -* 页面的区块类型 -* 用户插件的登入认证方法 -* 工作流插件的节点类型和触发类型 -* …… - -在具体业务中可以根据需求基于以上扩展点进行相应的扩展。 +基于 NocoBase 的微内核设计,在很多模块都考虑相应的可扩展性,并向外部暴露了一些扩展点,几乎覆盖全部的应用生命周期,在具体业务中可以根据需求基于众多扩展点进行相应的扩展。详情参考 [扩展索引](/development/guide)。 ## 插件生命周期 @@ -40,3 +28,6 @@ NocoBase 的核心是一个基于微内核、插件化设计的开发框架。 ## 学习路线 +1. 编写第一个插件 +2. 数据表建模 +3. diff --git a/packages/samples/shop-i18n/README.md b/packages/samples/shop-i18n/README.md new file mode 100644 index 000000000..9a6d6b7d5 --- /dev/null +++ b/packages/samples/shop-i18n/README.md @@ -0,0 +1,49 @@ +# Modeling for simple shop scenario + +## Register + +```ts +yarn pm add sample-shop-modeling +``` + +## Activate + +```bash +yarn pm enable sample-shop-modeling +``` + +## Launch the app + +```bash +# for development +yarn dev + +# for production +yarn build +yarn start +``` + +## Connect to the API + +### Products API + +```bash +# create a product +curl -X POST -d '{"title": "iPhone 14 Pro", "price": "7999", "enabled": true, "inventory": 10}' "http://localhost:13000/api/products" + +# list products +curl "http://localhost:13000/api/products" + +# get product which id=1 +curl "http://localhost:13000/api/products?filterByTk=1" +``` + +### Orders API + +```bash +# create a order +curl -X POST -d '{"productId": 1, "quantity": 1, "totalPrice": "7999", "userId": 1}' 'http://localhost:13000/api/orders' + +# list orders which userId=1 with product +curl 'http://localhost:13000/api/orders?filter={"userId":1}&appends=product' +``` diff --git a/packages/samples/shop-i18n/client.d.ts b/packages/samples/shop-i18n/client.d.ts new file mode 100755 index 000000000..765db9222 --- /dev/null +++ b/packages/samples/shop-i18n/client.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; + diff --git a/packages/samples/shop-i18n/client.js b/packages/samples/shop-i18n/client.js new file mode 100755 index 000000000..238820257 --- /dev/null +++ b/packages/samples/shop-i18n/client.js @@ -0,0 +1,30 @@ +"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]; + } + }); +}); diff --git a/packages/samples/shop-i18n/package.json b/packages/samples/shop-i18n/package.json new file mode 100644 index 000000000..112d126fe --- /dev/null +++ b/packages/samples/shop-i18n/package.json @@ -0,0 +1,18 @@ +{ + "name": "@nocobase/plugin-sample-shop-i18n", + "version": "0.7.4-alpha.7", + "main": "lib/server/index.js", + "dependencies": { + "nodejs-snowflake": "2.0.1" + }, + "devDependencies": { + "@nocobase/client": "0.7.4-alpha.7", + "@nocobase/server": "0.7.4-alpha.7", + "@nocobase/test": "0.7.4-alpha.7" + }, + "peerDependencies": { + "@nocobase/client": "*", + "@nocobase/server": "*", + "@nocobase/test": "*" + } +} diff --git a/packages/samples/shop-i18n/server.d.ts b/packages/samples/shop-i18n/server.d.ts new file mode 100755 index 000000000..e70edb928 --- /dev/null +++ b/packages/samples/shop-i18n/server.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; + diff --git a/packages/samples/shop-i18n/server.js b/packages/samples/shop-i18n/server.js new file mode 100755 index 000000000..d06a7eb92 --- /dev/null +++ b/packages/samples/shop-i18n/server.js @@ -0,0 +1,30 @@ +"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]; + } + }); +}); diff --git a/packages/samples/shop-i18n/src/client/index.tsx b/packages/samples/shop-i18n/src/client/index.tsx new file mode 100644 index 000000000..db735f78d --- /dev/null +++ b/packages/samples/shop-i18n/src/client/index.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default React.memo((props) => { + return <>{props.children}; +}); diff --git a/packages/samples/shop-i18n/src/index.ts b/packages/samples/shop-i18n/src/index.ts new file mode 100644 index 000000000..7ddad5814 --- /dev/null +++ b/packages/samples/shop-i18n/src/index.ts @@ -0,0 +1 @@ +export { default } from './server'; diff --git a/packages/samples/shop-i18n/src/server/actions/.gitkeep b/packages/samples/shop-i18n/src/server/actions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/samples/shop-i18n/src/server/collections/.gitkeep b/packages/samples/shop-i18n/src/server/collections/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/samples/shop-i18n/src/server/collections/categories.ts b/packages/samples/shop-i18n/src/server/collections/categories.ts new file mode 100644 index 000000000..d3704b8b5 --- /dev/null +++ b/packages/samples/shop-i18n/src/server/collections/categories.ts @@ -0,0 +1,13 @@ +export default { + name: 'categories', + fields: [ + { + type: 'string', + name: 'title' + }, + { + type: 'hasMany', + name: 'products', + } + ] +}; diff --git a/packages/samples/shop-i18n/src/server/collections/orders.ts b/packages/samples/shop-i18n/src/server/collections/orders.ts new file mode 100644 index 000000000..339a6c6b3 --- /dev/null +++ b/packages/samples/shop-i18n/src/server/collections/orders.ts @@ -0,0 +1,25 @@ +export default { + name: 'orders', + fields: [ + { + type: 'belongsTo', + name: 'product' + }, + { + type: 'integer', + name: 'quantity' + }, + { + type: 'integer', + name: 'totalPrice' + }, + { + type: 'integer', + name: 'status' + }, + { + type: 'belongsTo', + name: 'user' + } + ] +}; diff --git a/packages/samples/shop-i18n/src/server/collections/products.ts b/packages/samples/shop-i18n/src/server/collections/products.ts new file mode 100644 index 000000000..99e9daf16 --- /dev/null +++ b/packages/samples/shop-i18n/src/server/collections/products.ts @@ -0,0 +1,21 @@ +export default { + name: 'products', + fields: [ + { + type: 'string', + name: 'title' + }, + { + type: 'integer', + name: 'price' + }, + { + type: 'boolean', + name: 'enabled' + }, + { + type: 'integer', + name: 'inventory' + } + ] +}; diff --git a/packages/samples/shop-i18n/src/server/index.ts b/packages/samples/shop-i18n/src/server/index.ts new file mode 100644 index 000000000..fc02118a4 --- /dev/null +++ b/packages/samples/shop-i18n/src/server/index.ts @@ -0,0 +1,65 @@ +import path from 'path'; + +import { InstallOptions, Plugin } from '@nocobase/server'; + +export class ShopPlugin extends Plugin { + getName(): string { + return this.getPackageName(__dirname); + } + + beforeLoad() { + // TODO + } + + async load() { + await this.db.import({ + directory: path.resolve(__dirname, 'collections'), + }); + + this.app.resource({ + name: 'order', + actions: { + async create(ctx, next) { + const productRepo = ctx.db.getRepository('products'); + const product = await productRepo.findOne({ + filterByTk: ctx.action.params.filterByTk + }); + + if (!product) { + return ctx.throw(404, ctx.t('No such product')); + } + + if (!product.enabled) { + return ctx.throw(400, ctx.t('Product not on sale')); + } + + if (!product.inventory) { + return ctx.throw(400, ctx.t('Out of stock')); + } + + const orderRepo = ctx.db.getRepository('orders'); + ctx.body = await orderRepo.create({ + values: { + productId: product.id, + quantity: 1, + totalPrice: product.price, + userId: ctx.state.currentUser.id + } + }); + + next(); + } + } + }); + + this.app.acl.allow('products', '*'); + this.app.acl.allow('categories', '*'); + this.app.acl.allow('orders', '*'); + } + + async install(options: InstallOptions) { + // TODO + } +} + +export default ShopPlugin; diff --git a/packages/samples/shop-i18n/src/server/models/.gitkeep b/packages/samples/shop-i18n/src/server/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/samples/shop-i18n/src/server/repositories/.gitkeep b/packages/samples/shop-i18n/src/server/repositories/.gitkeep new file mode 100644 index 000000000..e69de29bb