docs: add hooks dev doc (#868)
* docs: add hooks dev doc * docs: change name from hooks to events
This commit is contained in:
parent
3e30699581
commit
2efa704b3b
@ -51,7 +51,7 @@ export default {
|
||||
'/development/guide/resources-actions',
|
||||
'/development/guide/middleware',
|
||||
'/development/guide/commands',
|
||||
'/development/guide/hooks',
|
||||
'/development/guide/events',
|
||||
'/development/guide/i18n',
|
||||
'/development/guide/migration',
|
||||
{
|
||||
|
@ -512,17 +512,19 @@ asset(post1.authorId === author1.id); // true
|
||||
|
||||
Database 除了对 sequelize 原生的事件封装以外,还提供以下可监听事件类型:
|
||||
|
||||
| 事件名称 | 描述 |
|
||||
| --- | --- |
|
||||
| `'beforeDefineCollection'` | 定义 collection 之前触发 |
|
||||
| `'afterDefineCollection'` | 定义 collection 之后触发 |
|
||||
| `'beforeRemoveCollection'` | 移除 collection 之前触发 |
|
||||
| `'afterRemoveCollection'` | 移除 collection 之后触发 |
|
||||
| `<sequelize_model_global_event>` | 所有 sequelize 的全局事件均可通过此方式监听,详见示例部分 |
|
||||
| `<model_name>.<sequelize_model_event>` | 所有 sequelize model 的事件均可通过此方式监听,详见示例部分 |
|
||||
| `<model_name>.afterCreateWithAssociations` | NocoBase 扩展的当连同关联数据一并创建记录成功后触发的事件(使用 Repository 的 create 方法会触发) |
|
||||
| `<model_name>.afterUpdateWithAssociations` | NocoBase 扩展的当连同关联数据一并更新记录成功后触发的事件(使用 Repository 的 update 方法会触发) |
|
||||
| `<model_name>.afterSaveWithAssociations` | NocoBase 扩展的当连同关联数据一并创建或更新记录成功后触发的事件(使用 Repository 的 create/update 方法都会触发) |
|
||||
| 事件名称 | 是否异步 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `'beforeDefineCollection'` | 否 | 定义 collection 之前触发 |
|
||||
| `'afterDefineCollection'` | 否 | 定义 collection 之后触发 |
|
||||
| `'beforeRemoveCollection'` | 否 | 移除 collection 之前触发 |
|
||||
| `'afterRemoveCollection'` | 否 | 移除 collection 之后触发 |
|
||||
| `<sequelize_model_global_event>` | - | 所有 sequelize 的全局事件均可通过此方式监听,详见示例部分,是否异步根据具体事件 |
|
||||
| `<model_name>.<sequelize_model_event>` | 是 | 所有 sequelize model 的事件均可通过此方式监听,详见示例部分 |
|
||||
| `<model_name>.afterCreateWithAssociations` | 是 | NocoBase 扩展的当连同关联数据一并创建记录成功后触发的事件(使用 Repository 的 create 方法会触发) |
|
||||
| `<model_name>.afterUpdateWithAssociations` | 是 | NocoBase 扩展的当连同关联数据一并更新记录成功后触发的事件(使用 Repository 的 update 方法会触发) |
|
||||
| `<model_name>.afterSaveWithAssociations` | 是 | NocoBase 扩展的当连同关联数据一并创建或更新记录成功后触发的事件(使用 Repository 的 create/update 方法都会触发) |
|
||||
|
||||
其中 `<model_name>.afterXXXWithAssociations` 事件只有在使用 Repository 的实例方法时才会被触发,所以建议大部分时候都使用 Repository 来进行数据操作。
|
||||
|
||||
**签名**
|
||||
|
||||
|
176
docs/zh-CN/development/guide/events.md
Normal file
176
docs/zh-CN/development/guide/events.md
Normal file
@ -0,0 +1,176 @@
|
||||
# 事件
|
||||
|
||||
事件在很多插件化可扩展的框架和系统中都有应用,比如著名的 Wordpress,是比较广泛的对生命周期支持扩展的机制。
|
||||
|
||||
## 基础概念
|
||||
|
||||
NocoBase 在应用生命周期中提供了一些钩子,以便在运行中的一些特殊时期根据需要进行扩展开发。
|
||||
|
||||
### 数据库事件
|
||||
|
||||
主要通过 `db.on()` 的方法定义,大部分事件兼容 Sequelize 原生的事件类型。例如需要在某张数据表创建一条数据后做一些事情时,可以使用 `<collectionName>.afterCreate` 事件:
|
||||
|
||||
```ts
|
||||
// posts 表创建数据完成时触发
|
||||
db.on('posts.afterCreate', async (post, options) => {
|
||||
console.log(post);
|
||||
});
|
||||
```
|
||||
|
||||
由于 Sequelize 默认的单条数据创建成功触发的时间点上并未完成与该条数据所有关联数据的处理,所以 NocoBase 针对默认封装的 Repository 数据仓库类完成数据创建和更新操作时,扩展了几个额外的事件,代表关联数据被一并操作完成:
|
||||
|
||||
```ts
|
||||
// 已创建且已根据创建数据完成关联数据创建或更新完成时触发
|
||||
db.on('posts.afterCreateWithAssociations', async (post, options) => {
|
||||
console.log(post);
|
||||
});
|
||||
```
|
||||
|
||||
与 Sequelize 同样的也可以针对全局的数据处理都定义特定的事件:
|
||||
|
||||
```ts
|
||||
// 每张表创建数据完成都触发
|
||||
db.on('beforeCreate', async (model, options) => {
|
||||
console.log(model);
|
||||
});
|
||||
```
|
||||
|
||||
针对特殊的生命周期比如定义数据表等,NocoBase 也扩展了相应事件:
|
||||
|
||||
```ts
|
||||
// 定义任意数据表之前触发
|
||||
db.on('beforeDefineCollection', (collection) => {
|
||||
collection.options.tableName = 'somePrefix' + collection.options.tableName;
|
||||
});
|
||||
```
|
||||
|
||||
其他所有可用的数据库事件类型可以参考 [Database API](/api/database#on)。
|
||||
|
||||
### 应用级事件
|
||||
|
||||
在某些特殊需求时,会需要在应用的外层生命周期中定义事件进行扩展,比如当应用启动前做一些准备操作,当应用停止前做一些清理操作等:
|
||||
|
||||
```ts
|
||||
app.on('beforeStart', async () => {
|
||||
console.log('app is starting...');
|
||||
});
|
||||
|
||||
app.on('beforeStop', async () => {
|
||||
console.log('app is stopping...');
|
||||
});
|
||||
```
|
||||
|
||||
其他所有可用的应用级事件类型可以参考 [Application API](/api/server/application#事件)。
|
||||
|
||||
## 示例
|
||||
|
||||
我们继续以简单的在线商店来举例,相关的数据表建模可以回顾 [数据表和字段](/development/) 部分的示例。
|
||||
|
||||
### 创建订单后减商品库存
|
||||
|
||||
通常我们的商品和订单是不同的数据表,而客户在下单以后把商品的库存减掉可以解决超卖的问题,这时候我们可以针对创建订单这个数据操作定义相应的事件,在这个时机一并解决库存修改的问题:
|
||||
|
||||
```ts
|
||||
class ShopPlugin extends Plugin {
|
||||
load() {
|
||||
this.db.on('orders.afterCreate', async (order, options) => {
|
||||
const product = await order.getProduct({
|
||||
transaction: options.transaction
|
||||
});
|
||||
|
||||
await product.update({
|
||||
inventory: product.inventory - order.quantity
|
||||
}, {
|
||||
transaction: options.transaction
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
因为默认 Sequelize 的事件中就携带事务等信息,所以我们可以直接使用 transaction 以保证两个数据操作都在同一事务中进行。
|
||||
|
||||
同样的,也可以在创建发货记录后修改订单状态为已发货:
|
||||
|
||||
```ts
|
||||
class ShopPlugin extends Plugin {
|
||||
load() {
|
||||
this.db.on('deliveries.afterCreate', async (delivery, options) => {
|
||||
const orderRepo = this.db.getRepository('orders');
|
||||
await orderRepo.update({
|
||||
filterByTk: delivery.orderId,
|
||||
value: {
|
||||
status: 2
|
||||
}
|
||||
transaction: options.transaction
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 随应用同时存在的定时任务
|
||||
|
||||
在不考虑使用工作流插件等复杂情况下,我们也可以通过应用级的事件实现一个简单的定时任务机制,且可以与应用的进程绑定,退出后就停止。比如我们希望定时扫描所有订单,超过签收时间后自动签收:
|
||||
|
||||
```ts
|
||||
class ShopPlugin extends Plugin {
|
||||
timer = null;
|
||||
orderReceiveExpires = 86400 * 7;
|
||||
|
||||
checkOrder = async () => {
|
||||
const expiredDate = new Date(Date.now() - this.orderReceiveExpires);
|
||||
const deliveryRepo = this.db.getRepository('deliveries');
|
||||
const expiredDeliveries = await deliveryRepo.find({
|
||||
fields: ['id', 'orderId'],
|
||||
filter: {
|
||||
status: 0,
|
||||
createdAt: {
|
||||
$lt: expiredDate
|
||||
}
|
||||
}
|
||||
});
|
||||
await deliveryRepo.update({
|
||||
filter: {
|
||||
id: expiredDeliveries.map(item => item.get('id')),
|
||||
},
|
||||
values: {
|
||||
status: 1
|
||||
}
|
||||
});
|
||||
const orderRepo = this.db.getRepository('orders');
|
||||
const [updated] = await orderRepo.update({
|
||||
filter: {
|
||||
status: 2,
|
||||
id: expiredDeliveries.map(item => item.get('orderId'))
|
||||
},
|
||||
values: {
|
||||
status: 3
|
||||
}
|
||||
});
|
||||
|
||||
console.log('%d orders expired', updated);
|
||||
};
|
||||
|
||||
load() {
|
||||
this.app.on('beforeStart', () => {
|
||||
// 每分钟执行一次
|
||||
this.timer = setInterval(this.checkOrder, 1000 * 60);
|
||||
});
|
||||
|
||||
this.app.on('beforeStop', () => {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
通过上面的示例,我们基本了解了事件的作用和可以用于扩展的方式:
|
||||
|
||||
* 数据库相关的事件
|
||||
* 应用相关的事件
|
||||
|
||||
本章涉及的示例代码整合在对应的包 [packages/samples/shop-events](https://github.com/nocobase/nocobase/tree/main/packages/samples/shop-events) 中,可以直接在本地运行,查看效果。
|
@ -1 +0,0 @@
|
||||
# Hooks
|
@ -1,4 +1,4 @@
|
||||
# Modeling for simple shop scenario
|
||||
# Actions for simple shop scenario
|
||||
|
||||
## Register
|
||||
|
||||
|
44
packages/samples/shop-events/README.md
Normal file
44
packages/samples/shop-events/README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Hooks for simple shop scenario
|
||||
|
||||
## Register
|
||||
|
||||
```ts
|
||||
yarn pm add sample-shop-hooks
|
||||
```
|
||||
|
||||
## Activate
|
||||
|
||||
```bash
|
||||
yarn pm enable sample-shop-hooks
|
||||
```
|
||||
|
||||
## 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 -H "Content-Type: application/json" -d '{"title": "iPhone 14 Pro", "price": 7999, "enabled": true, "inventory": 1}' "http://localhost:13000/api/products"
|
||||
```
|
||||
|
||||
### Orders API
|
||||
|
||||
```bash
|
||||
# create a order
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"productId": 1, "quantity": 1, "totalPrice": 0, "userId": 2}' 'http://localhost:13000/api/orders'
|
||||
# {"id": <id>, "status": 0, "productId": 1, "quantity": 1, "totalPrice": 7999, "userId": 1}
|
||||
|
||||
# create an expired delivery to watch schedule task
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"orderId": 1, "provider": "SF", "trackingNumber": "123456789", "userId": 2, "createdAt": "2022-09-01T00:00:00Z"}' 'http://localhost:13000/api/deliveries'
|
||||
```
|
4
packages/samples/shop-events/client.d.ts
vendored
Executable file
4
packages/samples/shop-events/client.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/client';
|
||||
export { default } from './lib/client';
|
||||
|
30
packages/samples/shop-events/client.js
Executable file
30
packages/samples/shop-events/client.js
Executable file
@ -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];
|
||||
}
|
||||
});
|
||||
});
|
17
packages/samples/shop-events/package.json
Normal file
17
packages/samples/shop-events/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-sample-shop-hooks",
|
||||
"version": "0.7.4-alpha.7",
|
||||
"main": "lib/server/index.js",
|
||||
"dependencies": {
|
||||
},
|
||||
"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": "*"
|
||||
}
|
||||
}
|
4
packages/samples/shop-events/server.d.ts
vendored
Executable file
4
packages/samples/shop-events/server.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/server';
|
||||
export { default } from './lib/server';
|
||||
|
30
packages/samples/shop-events/server.js
Executable file
30
packages/samples/shop-events/server.js
Executable file
@ -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];
|
||||
}
|
||||
});
|
||||
});
|
5
packages/samples/shop-events/src/client/index.tsx
Normal file
5
packages/samples/shop-events/src/client/index.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export default React.memo((props) => {
|
||||
return <>{props.children}</>;
|
||||
});
|
1
packages/samples/shop-events/src/index.ts
Normal file
1
packages/samples/shop-events/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './server';
|
@ -0,0 +1,13 @@
|
||||
export default {
|
||||
name: 'categories',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title'
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'products',
|
||||
}
|
||||
]
|
||||
};
|
@ -0,0 +1,22 @@
|
||||
export default {
|
||||
name: 'deliveries',
|
||||
fields: [
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'order'
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'provider'
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'trackingNumber'
|
||||
},
|
||||
{
|
||||
type: 'integer',
|
||||
name: 'status',
|
||||
defaultValue: 0
|
||||
}
|
||||
]
|
||||
};
|
@ -0,0 +1,33 @@
|
||||
export default {
|
||||
name: 'orders',
|
||||
fields: [
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'product'
|
||||
},
|
||||
{
|
||||
type: 'integer',
|
||||
name: 'quantity'
|
||||
},
|
||||
{
|
||||
type: 'integer',
|
||||
name: 'totalPrice'
|
||||
},
|
||||
{
|
||||
type: 'integer',
|
||||
name: 'status'
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'address'
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'user'
|
||||
},
|
||||
{
|
||||
type: 'hasOne',
|
||||
name: 'delivery'
|
||||
}
|
||||
]
|
||||
};
|
@ -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'
|
||||
}
|
||||
]
|
||||
};
|
88
packages/samples/shop-events/src/server/index.ts
Normal file
88
packages/samples/shop-events/src/server/index.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import path from 'path';
|
||||
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
|
||||
export class ShopPlugin extends Plugin {
|
||||
timer = null;
|
||||
orderReceiveExpires = 86400 * 7;
|
||||
|
||||
checkOrder = async () => {
|
||||
const expiredDate = new Date(Date.now() - this.orderReceiveExpires);
|
||||
const deliveryRepo = this.db.getRepository('deliveries');
|
||||
const expiredDeliveries = await deliveryRepo.find({
|
||||
fields: ['id', 'orderId'],
|
||||
filter: {
|
||||
status: 0,
|
||||
createdAt: {
|
||||
$lt: expiredDate
|
||||
}
|
||||
}
|
||||
});
|
||||
await deliveryRepo.update({
|
||||
filter: {
|
||||
id: expiredDeliveries.map(item => item.get('id')),
|
||||
},
|
||||
values: {
|
||||
status: 1
|
||||
}
|
||||
});
|
||||
const orderRepo = this.db.getRepository('orders');
|
||||
const [updated] = await orderRepo.update({
|
||||
filter: {
|
||||
status: 2,
|
||||
id: expiredDeliveries.map(item => item.get('orderId'))
|
||||
},
|
||||
values: {
|
||||
status: 3
|
||||
}
|
||||
});
|
||||
|
||||
console.log('%d orders expired', updated);
|
||||
};
|
||||
|
||||
getName(): string {
|
||||
return this.getPackageName(__dirname);
|
||||
}
|
||||
|
||||
beforeLoad() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.db.import({
|
||||
directory: path.resolve(__dirname, 'collections'),
|
||||
});
|
||||
|
||||
this.db.on('orders.afterCreate', async (order, options) => {
|
||||
const product = await order.getProduct({
|
||||
transaction: options.transaction
|
||||
});
|
||||
|
||||
await product.update({
|
||||
inventory: product.inventory - order.quantity
|
||||
}, {
|
||||
transaction: options.transaction
|
||||
});
|
||||
});
|
||||
|
||||
this.app.on('beforeStart', () => {
|
||||
// 每分钟执行一次
|
||||
this.timer = setInterval(this.checkOrder, 1000 * 60);
|
||||
});
|
||||
|
||||
this.app.on('beforeStop', () => {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
});
|
||||
|
||||
this.app.acl.allow('products', '*');
|
||||
this.app.acl.allow('categories', '*');
|
||||
this.app.acl.allow('orders', '*');
|
||||
}
|
||||
|
||||
async install(options: InstallOptions) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
export default ShopPlugin;
|
Loading…
Reference in New Issue
Block a user