feat: new plugin manager, supports adding plugins through UI (#2430)
* refactor: plugin manager page * fix: bug * feat: addByNpm api * fix: improve the addByNpm * feat: improve applicationPlugins:list api * fix: re-download npm package when restart app * fix: plugin delete api * feat: plugin detail api * feat: zipUrl add api * fix: upload api bug * fix: plugin detail info * feat: upgrade api * fix: upload api * feat: handle plugin load error * feat: support authToken * feat: muti lang * fix: build error * fix: self review * Update plugin-manager.ts * fix: bug * fix: bug * fix: bug * fix: bug * fix: bug * fix: bugs * fix: detail click and remove isOfficial * fix: upgrade no refresh * fix: file size and type check * fix: bug * fix: upgrade error * fix: bug * fix: bug * fix: plugin card layout * fix: handling exceptional cases * fix: tgz file support * fix: macos compress file * fix: bug * fix: bug * fix: bug * fix: bug * fix: add upgrade npm type * fix: bugs * fix: bug * fix: change plugins static expose url * fix: api prefix * fix: bug * fix: add nginx `/static/plugin/` path * fix: bugs and pr docker build no dts * fix: bug * fix: build tools bug * fix: improve code * fix: build bug * feat: improve plugin info * fix: ui bug * fix: plugin document bug * feat: improve code * feat: improve code * feat: process dev deps check * feat: improve code * feat: process.env.IS_DEV_CMD * fix: do not delete the plugin package * feat: plugin symlink * fix: tsx watch --ignore=./storage/plugins/** * fix: test error * fix: improve code * fix: improve code * fix: emitStartedEvent * fix: improve code * fix: type error * fix: test error * test: console.log * fix: createStoragePluginSymLink * fix: clientStaticMiddleware rename to clientStaticUtils * feat: build tools support plugins folder * fix: 350px * fix: error * feat: client dev support plugin folder * fix: clear cli options * fix: typeError: Converting circular structure to JSON * fix: plugin name * chore: restart application after command * feat: upgrade error & docs * Update v14-changelog.md * Update v14-changelog.md * Update v14-changelog.md * fix: gateway test * refactor(plugin-workflow): add ready state for gracefully tearing down * Revert "chore: restart application after command" This reverts commit 5015274f8e4e06e506e15754b672330330e8c7f8. * chore: stop application whe restart * T 1218 change plugin folder (#2629) * feat: change folder name * feat: change `pm create` command * feat: revert plugin name change * fix: delete samples * feat: change plugins folder * fix: pm create * feat: update docs * fix: link package error * fix: docs * fix: create command * fix: pm add error * fix: create add build * fix: pm creatre + add * feat: add tar command * fix: docs * fix: bug * fix: docs --------- Co-authored-by: chenos <chenlinxh@gmail.com> * feat: docs * Update your-fisrt-plugin.md * Update your-fisrt-plugin.md * chore: application reload * chore: test * fix: pm add error * chore: preset install skip exists plugin * fix: createIfNotExists --------- Co-authored-by: chenos <chenlinxh@gmail.com> Co-authored-by: chareice <chareice@live.com> Co-authored-by: Zhou <zhou.working@gmail.com> Co-authored-by: mytharcher <mytharcher@gmail.com>
This commit is contained in:
parent
b918ec5e41
commit
705b7449f0
@ -26,3 +26,4 @@ packages/core/database/src/sql-parser/index.js
|
||||
packages/core/cli/templates/plugin/src/client/*.tpl
|
||||
packages/app/client/src/.plugins
|
||||
docker
|
||||
storage
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -26,3 +26,7 @@ storage/duplicator/*
|
||||
**/.dumi/tmp-production
|
||||
packages/core/client/docs/contributing.md
|
||||
packages/core/app/client/src/.plugins
|
||||
storage/plugins
|
||||
storage/tar
|
||||
storage/tmp
|
||||
storage/app.watch.ts
|
||||
|
@ -16,3 +16,4 @@ packages/core/client/src/locale/*
|
||||
packages/core/cli/templates/plugin/src/client/*.tpl
|
||||
packages/app/client/src/.plugins
|
||||
packages/**/locale/**
|
||||
storage
|
||||
|
@ -12,7 +12,7 @@ RUN cd /tmp && \
|
||||
NEWVERSION="$(cat lerna.json | jq '.version' | tr -d '"').$(date +'%Y%m%d%H%M%S')" \
|
||||
&& tmp=$(mktemp) \
|
||||
&& jq ".version = \"${NEWVERSION}\"" lerna.json > "$tmp" && mv "$tmp" lerna.json
|
||||
RUN yarn install && yarn build
|
||||
RUN yarn install && yarn build --no-dts
|
||||
|
||||
RUN git checkout -b release \
|
||||
&& yarn version:alpha -y \
|
||||
|
@ -8,6 +8,7 @@ NocoBase is in early stage of development and is subject to frequent changes, pl
|
||||
|
||||
## Recent major updates
|
||||
|
||||
- [v0.14: New plugin manager, supports adding plugins through UI - 2023/09/11](https://docs.nocobase.com/welcome/release/v14-changelog)
|
||||
- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog)
|
||||
- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog)
|
||||
- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog)
|
||||
|
@ -8,6 +8,7 @@ NocoBase 正处在早期开发阶段,可能变动频繁,请谨慎用于生
|
||||
|
||||
## 最近重要更新
|
||||
|
||||
- [v0.14:全新的插件管理器,支持通过界面添加插件 - 2023/09/11](https://docs-cn.nocobase.com/welcome/release/v14-changelog)
|
||||
- [v0.13: 全新的应用状态流转 - 2023/08/24](https://docs-cn.nocobase.com/welcome/release/v13-changelog)
|
||||
- [v0.12: 全新的插件构建工具 - 2023/08/01](https://docs-cn.nocobase.com/welcome/release/v12-changelog)
|
||||
- [v0.11: 全新的客户端 Application、Plugin 和 Router - 2023/07/08](https://docs-cn.nocobase.com/welcome/release/v11-changelog)
|
||||
|
@ -66,6 +66,19 @@ server {
|
||||
send_timeout 600;
|
||||
}
|
||||
|
||||
location ^~ /static/plugins/ {
|
||||
proxy_pass http://127.0.0.1:13000/static/plugins/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
send_timeout 600;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:13000/ws;
|
||||
proxy_http_version 1.1;
|
||||
|
@ -84,6 +84,7 @@ const sidebar = {
|
||||
},
|
||||
// '/welcome/release/index',
|
||||
// '/welcome/release/v08-changelog',
|
||||
'/welcome/release/v14-changelog',
|
||||
'/welcome/release/v13-changelog',
|
||||
'/welcome/release/v12-changelog',
|
||||
'/welcome/release/v11-changelog',
|
||||
|
@ -12,10 +12,10 @@ Once NocoBase is installed, we can start our plugin development journey.
|
||||
First, you can quickly create an empty plugin via the CLI with the following command.
|
||||
|
||||
```bash
|
||||
yarn pm create hello
|
||||
yarn pm create @my-project/plugin-hello
|
||||
```
|
||||
|
||||
The directory where the plugin is located ``packages/plugins/hello`` and the plugin directory structure is
|
||||
The directory where the plugin located is `packages/plugins/@my-project/plugin-hello` and the plugin directory structure is
|
||||
|
||||
```bash
|
||||
|- /hello
|
||||
@ -27,34 +27,26 @@ The directory where the plugin is located ``packages/plugins/hello`` and the plu
|
||||
|- package.json # plugin package information
|
||||
|- server.d.ts
|
||||
|- server.js
|
||||
package.json
|
||||
|
||||
package.json information
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@nocobase/plugin-hello",
|
||||
"version": "0.1.0",
|
||||
"main": "lib/server/index.js",
|
||||
"devDependencies": {
|
||||
"@nocobase/client": "0.8.0-alpha.1",
|
||||
"@nocobase/test": "0.8.0-alpha.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A NocoBase plugin is also an NPM package, the correspondence rule between plugin name and NPM package name is `${PLUGIN_PACKAGE_PREFIX}-${pluginName}`.
|
||||
Visit the Plugin Manager to view the plugin you just added, the default address is http://localhost:13000/admin/pm/list/local/
|
||||
|
||||
`PLUGIN_PACKAGE_PREFIX` is the plugin package prefix, which can be customized in .env, [click here for PLUGIN_PACKAGE_PREFIX description](/api/env#plugin_package_prefix).
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/b04d16851fc1bbc2796ecf8f9bc0c3f4.png" />
|
||||
|
||||
If the plugin is not shown in the plugin manager, you can add it manually with the `pm add` command.
|
||||
|
||||
```bash
|
||||
yarn pm add @my-project/plugin-hello
|
||||
```
|
||||
|
||||
## Code the plugin
|
||||
|
||||
Look at the `packages/plugins/hello/src/server/plugin.ts` file and modify it to
|
||||
Look at the `packages/plugins/@my-project/plugin-hello/src/server/plugin.ts` file and modify it to
|
||||
|
||||
```ts
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
|
||||
export class HelloPlugin extends Plugin {
|
||||
export class PluginHelloServer extends Plugin {
|
||||
afterAdd() {}
|
||||
|
||||
beforeLoad() {}
|
||||
@ -62,9 +54,7 @@ export class HelloPlugin extends Plugin {
|
||||
async load() {
|
||||
this.db.collection({
|
||||
name: 'hello',
|
||||
fields: [
|
||||
{ type: 'string', name: 'name' }
|
||||
],
|
||||
fields: [{ type: 'string', name: 'name' }],
|
||||
});
|
||||
this.app.acl.allow('hello', '*');
|
||||
}
|
||||
@ -78,24 +68,29 @@ export class HelloPlugin extends Plugin {
|
||||
async remove() {}
|
||||
}
|
||||
|
||||
export default HelloPlugin;
|
||||
```
|
||||
|
||||
## Register the plugin
|
||||
|
||||
```bash
|
||||
yarn pm add hello
|
||||
export default PluginHelloServer;
|
||||
```
|
||||
|
||||
## Activate the plugin
|
||||
|
||||
When the plugin is activated, the hello table that you just configured is automatically created.
|
||||
**Operated by command**
|
||||
|
||||
```bash
|
||||
yarn pm enable hello
|
||||
yarn pm enable @my-project/plugin-hello
|
||||
```
|
||||
|
||||
## Start the application
|
||||
**Operated by UI**
|
||||
|
||||
Visit the Plugin Manager to view the plugin you just added and click enable.
|
||||
The Plugin Manager page defaults to http://localhost:13000/admin/pm/list/local/
|
||||
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/7b7df26a8ecc32bb1ebc3f99767ff9f9.png" />
|
||||
|
||||
Node: When the plugin is activated, the hello collection that you just configured is automatically created.
|
||||
|
||||
## Debug the Plugin
|
||||
|
||||
If the app is not started, you need to start the app first
|
||||
|
||||
```bash
|
||||
# for development
|
||||
@ -106,9 +101,7 @@ yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Experience the plugin
|
||||
|
||||
Insert data into the hello table of the plugin
|
||||
Insert data into the hello collection of the plugin
|
||||
|
||||
```bash
|
||||
curl --location --request POST 'http://localhost:13000/api/hello:create' \
|
||||
@ -123,3 +116,21 @@ View the data
|
||||
```bash
|
||||
curl --location --request GET 'http://localhost:13000/api/hello:list'
|
||||
```
|
||||
|
||||
## Build the plugin
|
||||
|
||||
```bash
|
||||
yarn build plugins/@my-project/plugin-hello --tar
|
||||
|
||||
# step-by-step
|
||||
yarn build plugins/@my-project/plugin-hello
|
||||
yarn nocobase tar plugins/@my-project/plugin-hello
|
||||
```
|
||||
|
||||
The default saved path for the plugin tar is `storage/tar/@my-project/plugin-hello.tar.gz`
|
||||
|
||||
## Upload to other NocoBase applications
|
||||
|
||||
Only supported in v0.14 and above
|
||||
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/8aa8a511aa8c1e87a8f7ee82cf8a1359.gif" />
|
||||
|
59
docs/en-US/welcome/release/v14-changelog.md
Normal file
59
docs/en-US/welcome/release/v14-changelog.md
Normal file
@ -0,0 +1,59 @@
|
||||
# v0.14: New plugin manager, supports adding plugins through UI
|
||||
|
||||
This release enables plug-and-play plugins in production environments. You can now add plugins directly through the UI, and support downloading from the npm registry (which can be private), local uploads, and URL downloads.
|
||||
|
||||
## New features
|
||||
|
||||
### New plugin manager interface
|
||||
|
||||
<img src="https://demo-cn.nocobase.com/storage/uploads/6de7c906518b6c6643570292523b06c8.png" />
|
||||
|
||||
### Uploaded plugins are located in the storage/plugins directory.
|
||||
|
||||
The storage/plugins directory is used to upload plugins, and is organized as npm packages.
|
||||
|
||||
```bash
|
||||
|- /storage/
|
||||
|- /plugins/
|
||||
|- /@nocobase/
|
||||
|- /plugin-hello1/
|
||||
|- /plugin-hello2/
|
||||
|- /@foo/
|
||||
|- /bar/
|
||||
|- /my-nocobase-plugin-hello/
|
||||
```
|
||||
|
||||
### Plugin updates
|
||||
|
||||
Currently, only plugins under storage/plugins can be updated, as shown here:
|
||||
|
||||
<img src="https://demo-cn.nocobase.com/storage/uploads/703809b8cd74cc95e1ab2ab766980817.gif" />
|
||||
|
||||
Note: In order to facilitate maintenance and upgrading, and to avoid unavailability of the storage plugins due to upgrading, you can put the new plugin directly into storage/plugins and then perform the upgrade operation.
|
||||
|
||||
## Incompatible changes
|
||||
|
||||
### Changes to plugin names
|
||||
|
||||
- PLUGIN_PACKAGE_PREFIX environment variable is no longer provided.
|
||||
- Plugin names and package names are unified, old plugin names can still exist as aliases.
|
||||
|
||||
### Improvements to pm.add
|
||||
|
||||
```bash
|
||||
# Use packageName instead of pluginName, lookup locally, error if not found
|
||||
pm add packageName
|
||||
|
||||
# Download from remote only if registry is provided, can also specify version
|
||||
pm add packageName --registry=xx --auth-token=yy --version=zz
|
||||
|
||||
# You can also provide a local zip, add multiple times and replace it with the last one
|
||||
pm add /a/plugin.zip
|
||||
|
||||
# Remote zip, replace it with the same name
|
||||
pm add http://url/plugin.zip
|
||||
```
|
||||
|
||||
## Plugin development guide
|
||||
|
||||
[Develop the first plugin](/development/your-fisrt-plugin)
|
@ -9,16 +9,16 @@
|
||||
|
||||
## 创建插件
|
||||
|
||||
首先,你可以通过 CLI 快速的创建一个空插件,命令如下:
|
||||
通过 CLI 快速地创建一个空插件,命令如下:
|
||||
|
||||
```bash
|
||||
yarn pm create hello
|
||||
yarn pm create @my-project/plugin-hello
|
||||
```
|
||||
|
||||
插件所在目录 `packages/plugins/hello`,插件目录结构为:
|
||||
插件所在目录 `packages/plugins/@my-project/plugin-hello`,插件目录结构为:
|
||||
|
||||
```bash
|
||||
|- /hello
|
||||
|- /packages/plugins/@my-project/plugin-hello
|
||||
|- /src
|
||||
|- /client # 插件客户端代码
|
||||
|- /server # 插件服务端代码
|
||||
@ -29,32 +29,24 @@ yarn pm create hello
|
||||
|- server.js
|
||||
```
|
||||
|
||||
package.json 信息
|
||||
访问插件管理器界面,查看刚添加的插件,默认地址为 http://localhost:13000/admin/pm/list/local/
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@nocobase/plugin-hello",
|
||||
"version": "0.1.0",
|
||||
"main": "lib/server/index.js",
|
||||
"devDependencies": {
|
||||
"@nocobase/client": "0.8.0-alpha.1",
|
||||
"@nocobase/test": "0.8.0-alpha.1"
|
||||
}
|
||||
}
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/b04d16851fc1bbc2796ecf8f9bc0c3f4.png" />
|
||||
|
||||
如果创建的插件未在插件管理器里显示,可以通过 `pm add` 命令手动添加
|
||||
|
||||
```bash
|
||||
yarn pm add @my-project/plugin-hello
|
||||
```
|
||||
|
||||
NocoBase 插件也是 NPM 包,插件名和 NPM 包名的对应规则为 `${PLUGIN_PACKAGE_PREFIX}-${pluginName}`。
|
||||
|
||||
`PLUGIN_PACKAGE_PREFIX` 为插件包前缀,可以在 .env 里自定义,[点此查看 PLUGIN_PACKAGE_PREFIX 说明](/api/env#plugin_package_prefix)。
|
||||
|
||||
## 编写插件
|
||||
|
||||
查看 `packages/plugins/hello/src/server/plugin.ts` 文件,并修改为:
|
||||
查看 `packages/plugins/@my-project/plugin-hello/src/server/plugin.ts` 文件,并修改为:
|
||||
|
||||
```ts
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
|
||||
export class HelloPlugin extends Plugin {
|
||||
export class PluginHelloServer extends Plugin {
|
||||
afterAdd() {}
|
||||
|
||||
beforeLoad() {}
|
||||
@ -62,9 +54,7 @@ export class HelloPlugin extends Plugin {
|
||||
async load() {
|
||||
this.db.collection({
|
||||
name: 'hello',
|
||||
fields: [
|
||||
{ type: 'string', name: 'name' }
|
||||
],
|
||||
fields: [{ type: 'string', name: 'name' }],
|
||||
});
|
||||
this.app.acl.allow('hello', '*');
|
||||
}
|
||||
@ -78,24 +68,29 @@ export class HelloPlugin extends Plugin {
|
||||
async remove() {}
|
||||
}
|
||||
|
||||
export default HelloPlugin;
|
||||
```
|
||||
|
||||
## 注册插件
|
||||
|
||||
```bash
|
||||
yarn pm add hello
|
||||
export default PluginHelloServer;
|
||||
```
|
||||
|
||||
## 激活插件
|
||||
|
||||
插件激活时,会自动创建刚才编辑插件配置的 hello 表。
|
||||
**通过命令操作**
|
||||
|
||||
```bash
|
||||
yarn pm enable hello
|
||||
yarn pm enable @my-project/plugin-hello
|
||||
```
|
||||
|
||||
## 启动应用
|
||||
**通过界面操作**
|
||||
|
||||
访问插件管理器界面,查看刚添加的插件,点击激活。
|
||||
插件管理器页面默认为 http://localhost:13000/admin/pm/list/local/
|
||||
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/7b7df26a8ecc32bb1ebc3f99767ff9f9.png" />
|
||||
|
||||
备注:插件激活时,会自动创建刚才编辑插件配置的 hello 表。
|
||||
|
||||
## 调试插件
|
||||
|
||||
如果应用未启动,需要先启动应用
|
||||
|
||||
```bash
|
||||
# for development
|
||||
@ -106,8 +101,6 @@ yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
## 体验插件功能
|
||||
|
||||
向插件的 hello 表里插入数据
|
||||
|
||||
```bash
|
||||
@ -123,3 +116,21 @@ curl --location --request POST 'http://localhost:13000/api/hello:create' \
|
||||
```bash
|
||||
curl --location --request GET 'http://localhost:13000/api/hello:list'
|
||||
```
|
||||
|
||||
## 构建并打包插件
|
||||
|
||||
```bash
|
||||
yarn build plugins/@my-project/plugin-hello --tar
|
||||
|
||||
# 分步骤
|
||||
yarn build plugins/@my-project/plugin-hello
|
||||
yarn nocobase tar plugins/@my-project/plugin-hello
|
||||
```
|
||||
|
||||
打包的插件默认保存路径为 `storage/tar/@my-project/plugin-hello.tar.gz`
|
||||
|
||||
## 上传至其他 NocoBase 应用
|
||||
|
||||
仅 v0.14 及以上版本支持
|
||||
|
||||
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/8aa8a511aa8c1e87a8f7ee82cf8a1359.gif" />
|
||||
|
101
docs/zh-CN/welcome/release/v14-changelog.md
Normal file
101
docs/zh-CN/welcome/release/v14-changelog.md
Normal file
@ -0,0 +1,101 @@
|
||||
# v0.14:全新的插件管理器,支持通过界面添加插件
|
||||
|
||||
v0.14 实现了生产环境下插件的即插即用,可以直接通过界面添加插件,支持从 npm registry(可以是私有的)下载、本地上传、URL 下载。
|
||||
|
||||
## 新特性
|
||||
|
||||
### 全新的插件管理器界面
|
||||
|
||||
<img src="https://demo-cn.nocobase.com/storage/uploads/6de7c906518b6c6643570292523b06c8.png" />
|
||||
|
||||
### 上传的插件位于 storage/plugins 目录
|
||||
|
||||
提供 storage/plugins 目录用于上传即插即用的插件,目录以 npm packages 的方式组织
|
||||
|
||||
```bash
|
||||
|- /storage/
|
||||
|- /plugins/
|
||||
|- /@nocobase/
|
||||
|- /plugin-hello1/
|
||||
|- /plugin-hello2/
|
||||
|- /my-nocobase-plugin-hello1/
|
||||
|- /my-nocobase-plugin-hello2/
|
||||
```
|
||||
|
||||
### 插件的更新
|
||||
|
||||
目前仅 storage/plugins 下的插件才有更新操作,如图:
|
||||
|
||||
<img src="https://demo-cn.nocobase.com/storage/uploads/703809b8cd74cc95e1ab2ab766980817.gif" />
|
||||
|
||||
备注:为了便于维护和升级,避免因为升级导致 storage 插件不可用,也可以直接将新插件放到 storage/plugins 目录下,再执行升级操作
|
||||
|
||||
## 不兼容的变化
|
||||
|
||||
### 插件目录变更
|
||||
|
||||
开发中的插件统一都放到 packages/plugins 目录下,以 npm packages 的方式组织
|
||||
|
||||
```diff
|
||||
|- /packages/
|
||||
- |- /plugins/acl/
|
||||
+ |- /plugins/@nocobase/plugin-acl/
|
||||
- |- /samples/hello/
|
||||
+ |- /plugins/@nocobase/plugin-sample-hello/
|
||||
```
|
||||
|
||||
全新的目录结构为
|
||||
|
||||
```bash
|
||||
# 开发中的插件
|
||||
|- /packages/
|
||||
|- /plugins/
|
||||
|- /@nocobase/
|
||||
|- /plugin-hello1/
|
||||
|- /plugin-hello2/
|
||||
|- /my-nocobase-plugin-hello1/
|
||||
|- /my-nocobase-plugin-hello2/
|
||||
|
||||
# 通过界面添加的插件
|
||||
|- /storage/
|
||||
|- /plugins/
|
||||
|- /@nocobase/
|
||||
|- /plugin-hello1/
|
||||
|- /plugin-hello2/
|
||||
|- /my-nocobase-plugin-hello1/
|
||||
|- /my-nocobase-plugin-hello2/
|
||||
```
|
||||
|
||||
### 插件名的变化
|
||||
|
||||
- 不再提供 PLUGIN_PACKAGE_PREFIX 环境变量
|
||||
- 插件名和包名统一,旧的插件名仍然可以以别名的形式存在
|
||||
|
||||
### pm add 的改进
|
||||
|
||||
变更情况
|
||||
|
||||
```diff
|
||||
- pm add sample-hello
|
||||
+ pm add @nocobase/plugin-sample-hello
|
||||
```
|
||||
|
||||
pm add 参数说明
|
||||
|
||||
```bash
|
||||
# 用 packageName 代替 pluginName,从本地查找,找不到报错
|
||||
pm add packageName
|
||||
|
||||
# 只有提供了 registry 时,才从远程下载,也可以指定版本
|
||||
pm add packageName --registry=xx --auth-token=yy --version=zz
|
||||
|
||||
# 也可以提供本地压缩包,多次 add 用最后的替换
|
||||
pm add /a/plugin.zip
|
||||
|
||||
# 远程压缩包,同名直接替换
|
||||
pm add http://url/plugin.zip
|
||||
```
|
||||
|
||||
## 插件开发指南
|
||||
|
||||
[编写第一个插件](/development/your-fisrt-plugin)
|
@ -34,5 +34,6 @@ module.exports = {
|
||||
'package.json',
|
||||
'/demo/',
|
||||
'package-lock.json',
|
||||
'/storage/',
|
||||
],
|
||||
};
|
||||
|
@ -2,7 +2,8 @@
|
||||
"name": "nocobase",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*/*"
|
||||
"packages/*/*",
|
||||
"packages/*/*/*"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@ -21,6 +22,7 @@
|
||||
"dev-server": "nocobase dev --server",
|
||||
"start": "nocobase start",
|
||||
"build": "nocobase build",
|
||||
"tar": "nocobase tar",
|
||||
"test": "nocobase test",
|
||||
"test:client": "vitest",
|
||||
"tc": "yarn test:client",
|
||||
|
@ -18,17 +18,17 @@
|
||||
"@types/lerna__project": "5.1.0",
|
||||
"@vercel/ncc": "0.36.1",
|
||||
"chalk": "2.4.2",
|
||||
"fast-glob": "3.3.0",
|
||||
"glob": "^7.1.4",
|
||||
"fast-glob": "^3.3.1",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-typescript": "6.0.0-alpha.1",
|
||||
"pkg-up": "3.1.0",
|
||||
"tsup": "7.2.0",
|
||||
"typescript": "5.1.3",
|
||||
"update-notifier": "3.0.0",
|
||||
"vite-plugin-css-injected-by-js": "^3.2.1",
|
||||
"vite-plugin-lib-inject-css": "1.2.0",
|
||||
"yargs-parser": "13.1.2"
|
||||
"yargs-parser": "13.1.2",
|
||||
"tar": "^6.2.0",
|
||||
"@types/tar": "^6.1.5"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
|
@ -17,8 +17,11 @@ import { buildDeclaration } from './buildDeclaration';
|
||||
import { PkgLog, getPkgLog, toUnixPath, getPackageJson } from './utils';
|
||||
import { getPackages } from './utils/getPackages';
|
||||
import { Package } from '@lerna/package';
|
||||
import { tarPlugin } from './tarPlugin'
|
||||
|
||||
export async function build(pkgs: string[]) {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const packages = getPackages(pkgs);
|
||||
const pluginPackages = getPluginPackages(packages);
|
||||
const cjsPackages = getCjsPackages(packages);
|
||||
@ -63,8 +66,17 @@ export async function buildPackage(
|
||||
) {
|
||||
const sourcemap = process.argv.includes('--sourcemap');
|
||||
const noDeclaration = process.argv.includes('--no-dts');
|
||||
const hasTar = process.argv.includes('--tar');
|
||||
const onlyTar = process.argv.includes('--only-tar');
|
||||
|
||||
const log = getPkgLog(pkg.name);
|
||||
const packageJson = getPackageJson(pkg.location);
|
||||
|
||||
if (onlyTar) {
|
||||
await tarPlugin(pkg.location, log);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`${chalk.bold(toUnixPath(pkg.location.replace(PACKAGES_PATH, '').slice(1)))} build start`);
|
||||
|
||||
// prebuild
|
||||
@ -88,6 +100,11 @@ export async function buildPackage(
|
||||
log('postbuild');
|
||||
await runScript(['postbuild'], pkg.location);
|
||||
}
|
||||
|
||||
// tar
|
||||
if (hasTar) {
|
||||
await tarPlugin(pkg.location, log);
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(args: string[], cwd: string, envs: Record<string, string> = {}) {
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
getPackagesFromFiles,
|
||||
getSourcePackages,
|
||||
} from './utils/buildPluginUtils';
|
||||
import { getDepsConfig } from './utils/getDepsConfig';
|
||||
import { getDepPkgPath, getDepsConfig } from './utils/getDepsConfig';
|
||||
import { EsbuildSupportExts, globExcludeFiles } from './constant';
|
||||
import { PkgLog, getPackageJson } from './utils';
|
||||
|
||||
@ -26,6 +26,8 @@ const serverGlobalFiles: string[] = ['src/**', '!src/client/**', ...globExcludeF
|
||||
const clientGlobalFiles: string[] = ['src/**', '!src/server/**', ...globExcludeFiles];
|
||||
const dynamicImportRegexp = /import\((["'])(.*?)\1\)/g;
|
||||
|
||||
const sourceGlobalFiles: string[] = ['src/**/*.{ts,js,tsx,jsx}', '!src/**/__tests__'];
|
||||
|
||||
const external = [
|
||||
// nocobase
|
||||
'@nocobase/acl',
|
||||
@ -138,6 +140,21 @@ export function deleteJsFiles(cwd: string, log: PkgLog) {
|
||||
});
|
||||
}
|
||||
|
||||
export function writeExternalPackageVersion(cwd: string, log: PkgLog) {
|
||||
log('write external version');
|
||||
const sourceFiles = fg.globSync(sourceGlobalFiles, { cwd, absolute: true }).map((item) => fs.readFileSync(item, 'utf-8'));
|
||||
const sourcePackages = getSourcePackages(sourceFiles);
|
||||
const excludePackages = getExcludePackages(sourcePackages, external, pluginPrefix);
|
||||
const data = excludePackages.reduce<Record<string, string>>((prev, packageName) => {
|
||||
const depPkgPath = getDepPkgPath(packageName, cwd);
|
||||
const depPkg = require(depPkgPath);
|
||||
prev[packageName] = depPkg.version;
|
||||
return prev;
|
||||
}, {});
|
||||
const externalVersionPath = path.join(cwd, target_dir, 'externalVersion.js');
|
||||
fs.writeFileSync(externalVersionPath, `module.exports = ${JSON.stringify(data, null, 2)};`);
|
||||
}
|
||||
|
||||
export async function buildServerDeps(cwd: string, serverFiles: string[], log: PkgLog) {
|
||||
log('build plugin server dependencies');
|
||||
const outDir = path.join(cwd, target_dir, 'node_modules');
|
||||
@ -382,4 +399,5 @@ export async function buildPlugin(cwd: string, sourcemap: boolean, log: PkgLog)
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
writeExternalPackageVersion(cwd, log);
|
||||
}
|
||||
|
@ -44,3 +44,7 @@ export const getCjsPackages = (packages: Package[]) =>
|
||||
.filter((item) => !PLUGINS_DIR.some((dir) => item.location.startsWith(dir)))
|
||||
.filter((item) => !item.location.startsWith(PRESETS_DIR))
|
||||
.filter((item) => !CJS_EXCLUDE_PACKAGES.includes(item.location));
|
||||
|
||||
// tar
|
||||
export const tarIncludesFiles = ['package.json', 'README.md', 'LICENSE', 'dist', '!node_modules', '!src'];
|
||||
export const TAR_OUTPUT_DIR = process.env.TAR_PATH ? process.env.TAR_PATH : path.join(ROOT_PATH, 'storage', 'tar');
|
||||
|
29
packages/core/build/src/tarPlugin.ts
Normal file
29
packages/core/build/src/tarPlugin.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import path from 'path';
|
||||
import tar from 'tar';
|
||||
import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { PkgLog } from "./utils";
|
||||
import { TAR_OUTPUT_DIR, tarIncludesFiles } from './constant'
|
||||
|
||||
export function tarPlugin(cwd: string, log: PkgLog) {
|
||||
log('tar package');
|
||||
const pkg = require(path.join(cwd, 'package.json'));
|
||||
const npmIgnore = path.join(cwd, '.npmignore');
|
||||
let files = pkg.files || [];
|
||||
if (fs.existsSync(npmIgnore)) {
|
||||
files = fs.readFileSync(npmIgnore, 'utf-8').split('\n').filter((item) => item.trim()).map(item => `!${item}`);
|
||||
files.push('**/*');
|
||||
}
|
||||
|
||||
// 必须包含的文件
|
||||
files.push(...tarIncludesFiles);
|
||||
|
||||
files = files.map((item: string) => item !== '**/*' && fs.existsSync(path.join(cwd, item.replace('!', ''))) && fs.statSync(path.join(cwd, item.replace('!', ''))).isDirectory() ? `${item}/**/*` : item);
|
||||
|
||||
const tarball = path.join(TAR_OUTPUT_DIR, `${pkg.name}-${pkg.version}.tgz`);
|
||||
const tarFiles = fg.sync(files, { cwd });
|
||||
|
||||
fs.mkdirpSync(path.dirname(tarball));
|
||||
return tar.c({ gzip: true, file: tarball, cwd }, tarFiles);
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import path from 'path';
|
||||
import pkgUp from 'pkg-up';
|
||||
|
||||
export function winPath(path: string) {
|
||||
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
|
||||
@ -40,9 +39,9 @@ export function getDepPkgPath(dep: string, cwd: string) {
|
||||
try {
|
||||
return require.resolve(`${dep}/package.json`, { paths: [cwd] });
|
||||
} catch {
|
||||
return pkgUp.sync({
|
||||
cwd: require.resolve(dep, { paths: [cwd] }),
|
||||
})!;
|
||||
const mainFile = require.resolve(`${dep}`, { paths: cwd ? [cwd] : undefined });
|
||||
const packageDir = mainFile.slice(0, mainFile.indexOf(dep.replace('/', path.sep)) + dep.length);
|
||||
return path.join(packageDir, 'package.json');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Topo from '@hapi/topo';
|
||||
import fg from 'fast-glob';
|
||||
import path from 'path';
|
||||
import { PACKAGES_PATH, ROOT_PATH } from '../constant';
|
||||
import { getPackagesSync } from '@lerna/project';
|
||||
import { Package } from '@lerna/package';
|
||||
@ -13,7 +14,13 @@ import { toUnixPath } from './utils';
|
||||
*/
|
||||
function getPackagesPath(pkgs: string[]) {
|
||||
if (pkgs.length === 0) {
|
||||
pkgs = ['*/*'];
|
||||
return fg
|
||||
.sync(['*/*/package.json', '*/*/*/package.json'], {
|
||||
cwd: PACKAGES_PATH,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})
|
||||
.map(toUnixPath).map(item => path.dirname(item))
|
||||
}
|
||||
return fg
|
||||
.sync(pkgs, {
|
||||
@ -36,7 +43,7 @@ export function getPackages(pkgs: string[]) {
|
||||
export function sortPackages(packages: Package[]): Package[] {
|
||||
const sorter = new Topo.Sorter<Package>();
|
||||
for (const pkg of packages) {
|
||||
const pkgJson = require(`${pkg.name}/package.json`);
|
||||
const pkgJson = require(`${pkg.location}/package.json`,);
|
||||
const after = Object.keys({ ...pkgJson.dependencies, ...pkgJson.devDependencies, ...pkgJson.peerDependencies });
|
||||
sorter.add(pkg, { after, group: pkg.name });
|
||||
}
|
||||
|
@ -15,7 +15,9 @@ const env = {
|
||||
DB_TIMEZONE: '+00:00',
|
||||
DEFAULT_STORAGE_TYPE: 'local',
|
||||
LOCAL_STORAGE_DEST: 'storage/uploads',
|
||||
PLUGIN_STORAGE_PATH: resolve(process.cwd(), 'storage/plugins'),
|
||||
MFSU_AD: 'none',
|
||||
NODE_MODULES_PATH: resolve(process.cwd(), 'node_modules'),
|
||||
PM2_HOME: resolve(process.cwd(), './storage/.pm2'),
|
||||
PLUGIN_PACKAGE_PREFIX: '@nocobase/plugin-,@nocobase/plugin-sample-,@nocobase/preset-',
|
||||
};
|
||||
|
@ -15,7 +15,7 @@
|
||||
"commander": "^9.2.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"execa": "^5.1.1",
|
||||
"fast-glob": "^3.2.12",
|
||||
"fast-glob": "^3.3.1",
|
||||
"fs-extra": "^11.1.1",
|
||||
"pm2": "^5.2.0",
|
||||
"portfinder": "^1.0.28",
|
||||
|
@ -18,6 +18,7 @@ module.exports = (cli) => {
|
||||
.allowUnknownOption()
|
||||
.action(async (opts) => {
|
||||
promptForTs();
|
||||
process.env.IS_DEV_CMD = true;
|
||||
|
||||
if (process.argv.includes('-h') || process.argv.includes('--help')) {
|
||||
run('ts-node', [
|
||||
@ -59,6 +60,7 @@ module.exports = (cli) => {
|
||||
|
||||
const argv = [
|
||||
'watch',
|
||||
'--ignore=./storage/plugins/**',
|
||||
'--tsconfig',
|
||||
'./tsconfig.server.json',
|
||||
'-r',
|
||||
|
@ -9,6 +9,7 @@ module.exports = (cli) => {
|
||||
generateAppDir();
|
||||
require('./global')(cli);
|
||||
require('./build')(cli);
|
||||
require('./tar')(cli);
|
||||
require('./dev')(cli);
|
||||
require('./start')(cli);
|
||||
require('./test')(cli);
|
||||
|
@ -3,6 +3,7 @@ const { run, isDev, isPackageValid } = require('../util');
|
||||
const { resolve } = require('path');
|
||||
const { existsSync } = require('fs');
|
||||
const { readFile, writeFile } = require('fs').promises;
|
||||
const { createStoragePluginsSymlink } = require('@nocobase/utils/plugin-symlink');
|
||||
|
||||
/**
|
||||
* @param {Command} cli
|
||||
@ -13,6 +14,7 @@ module.exports = (cli) => {
|
||||
.command('postinstall')
|
||||
.allowUnknownOption()
|
||||
.action(async () => {
|
||||
await createStoragePluginsSymlink();
|
||||
if (!isDev()) {
|
||||
return;
|
||||
}
|
||||
|
27
packages/core/cli/src/commands/tar.js
Normal file
27
packages/core/cli/src/commands/tar.js
Normal file
@ -0,0 +1,27 @@
|
||||
const { resolve } = require('path');
|
||||
const { Command } = require('commander');
|
||||
const { run, nodeCheck, isPackageValid } = require('../util');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Command} cli
|
||||
*/
|
||||
module.exports = (cli) => {
|
||||
cli
|
||||
.command('tar')
|
||||
.allowUnknownOption()
|
||||
.argument('[packages...]')
|
||||
.option('-v, --version', 'print version')
|
||||
.option('-c, --compile', 'compile the @nocobase/build package')
|
||||
.option('-w, --watch', 'watch compile the @nocobase/build package')
|
||||
.action(async (pkgs, options) => {
|
||||
nodeCheck();
|
||||
if (options.compile || options.watch || isPackageValid('@nocobase/build/src/index.ts')) {
|
||||
await run('yarn', ['build', options.watch ? '--watch' : ''], {
|
||||
cwd: resolve(process.cwd(), 'packages/core/build'),
|
||||
});
|
||||
if (options.watch) return;
|
||||
}
|
||||
await run('nocobase-build', [...pkgs, '--only-tar', options.version ? '--version' : '']);
|
||||
});
|
||||
};
|
@ -2,7 +2,9 @@ const chalk = require('chalk');
|
||||
const { existsSync } = require('fs');
|
||||
const { join, resolve } = require('path');
|
||||
const { Generator } = require('@umijs/utils');
|
||||
const { readFile } = require('fs').promises;
|
||||
const { readFile, writeFile } = require('fs').promises;
|
||||
|
||||
const execa = require('execa');
|
||||
|
||||
function camelize(str) {
|
||||
return str.trim().replace(/[-_\s]+(.)?/g, (match, c) => c.toUpperCase());
|
||||
@ -33,18 +35,27 @@ class PluginGenerator extends Generator {
|
||||
|
||||
async getContext() {
|
||||
const { name } = this.context;
|
||||
const packageName = await getProjectName();
|
||||
const nocobaseVersion = require('@nocobase/server/package.json').version;
|
||||
const packageVersion = await getProjectVersion();
|
||||
return {
|
||||
...this.context,
|
||||
packageName: `@${packageName}/plugin-${name}`,
|
||||
packageName: name,
|
||||
packageVersion,
|
||||
nocobaseVersion,
|
||||
pascalCaseName: capitalize(camelize(name)),
|
||||
pascalCaseName: capitalize(camelize(name.split('/').pop())),
|
||||
};
|
||||
}
|
||||
|
||||
async addTsConfigPaths() {
|
||||
const { name } = this.context;
|
||||
if (name.startsWith('@') && name.split('/')[0] !== '@nocobase') {
|
||||
const target = resolve(process.cwd(), 'tsconfig.json');
|
||||
const content = require(target);
|
||||
content.compilerOptions.paths[name] = [`packages/plugins/${name}/src`];
|
||||
await writeFile(target, JSON.stringify(content, null, 2), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
async writing() {
|
||||
const { name } = this.context;
|
||||
const target = resolve(process.cwd(), 'packages/plugins/', name);
|
||||
@ -59,6 +70,9 @@ class PluginGenerator extends Generator {
|
||||
path: join(__dirname, '../templates/plugin'),
|
||||
});
|
||||
console.log('');
|
||||
await this.addTsConfigPaths();
|
||||
execa.sync('yarn', ['install'], { shell: true, stdio: 'inherit' });
|
||||
// execa.sync('yarn', ['build', `plugins/${name}`], { shell: true, stdio: 'inherit' });
|
||||
console.log(`The plugin folder is in ${chalk.green(`packages/plugins/${name}`)}`);
|
||||
}
|
||||
}
|
||||
|
1
packages/core/cli/templates/plugin/README.md.tpl
Normal file
1
packages/core/cli/templates/plugin/README.md.tpl
Normal file
@ -0,0 +1 @@
|
||||
# {{{packageName}}}
|
@ -1,6 +1,6 @@
|
||||
import { Plugin } from '@nocobase/client';
|
||||
|
||||
export class {{{pascalCaseName}}}Plugin extends Plugin {
|
||||
export class {{{pascalCaseName}}}Client extends Plugin {
|
||||
async afterAdd() {
|
||||
// await this.app.pm.add()
|
||||
}
|
||||
@ -18,4 +18,4 @@ export class {{{pascalCaseName}}}Plugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
export default {{{pascalCaseName}}}Plugin;
|
||||
export default {{{pascalCaseName}}}Client;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
|
||||
export class {{{pascalCaseName}}}Plugin extends Plugin {
|
||||
export class {{{pascalCaseName}}}Server extends Plugin {
|
||||
afterAdd() {}
|
||||
|
||||
beforeLoad() {}
|
||||
@ -16,4 +16,4 @@ export class {{{pascalCaseName}}}Plugin extends Plugin {
|
||||
async remove() {}
|
||||
}
|
||||
|
||||
export default {{{pascalCaseName}}}Plugin;
|
||||
export default {{{pascalCaseName}}}Server;
|
||||
|
@ -40,7 +40,7 @@ export class PluginManager {
|
||||
|
||||
private async initRemotePlugins() {
|
||||
try {
|
||||
const res = await this.app.apiClient.request({ url: 'app:getPlugins' });
|
||||
const res = await this.app.apiClient.request({ url: 'pm:listEnabled' });
|
||||
const pluginList: PluginData[] = res?.data?.data || [];
|
||||
const plugins = await getPlugins({
|
||||
requirejs: this.app.requirejs,
|
||||
|
@ -10,7 +10,7 @@ import { Plugin } from '../Plugin';
|
||||
describe('Application', () => {
|
||||
beforeAll(() => {
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onGet('app:getPlugins').reply(200, {
|
||||
mock.onGet('pm:listEnabled').reply(200, {
|
||||
data: [],
|
||||
});
|
||||
});
|
||||
|
@ -6,7 +6,7 @@ import { Plugin } from '../Plugin';
|
||||
describe('Plugin', () => {
|
||||
beforeAll(() => {
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onGet('app:getPlugins').reply(200, {
|
||||
mock.onGet('pm:listEnabled').reply(200, {
|
||||
data: [],
|
||||
});
|
||||
});
|
||||
|
@ -11,7 +11,7 @@ describe('Router', () => {
|
||||
let app: Application;
|
||||
beforeAll(() => {
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onGet('app:getPlugins').reply(200, {
|
||||
mock.onGet('pm:listEnabled').reply(200, {
|
||||
data: [],
|
||||
});
|
||||
app = new Application();
|
||||
|
@ -21,13 +21,11 @@ export function getRemotePlugins(
|
||||
baseURL = baseURL.slice(0, -4);
|
||||
}
|
||||
|
||||
// for dynamic import `import()`
|
||||
(window as any).staticBaseUrl = `/api/plugins/client`;
|
||||
requirejs.requirejs.config({
|
||||
waitSeconds: 120,
|
||||
paths: pluginData.reduce<Record<string, string>>((memo, item) => {
|
||||
memo[item.packageName] = `${baseURL}${item.url}`;
|
||||
memo[`${item.packageName}/client`] = `${baseURL}${item.url}.js?client`;
|
||||
memo[item.packageName] = `${baseURL}${item.url}?noExt`;
|
||||
memo[`${item.packageName}/client`] = `${baseURL}${item.url}?noExtAndIsClient`;
|
||||
return memo;
|
||||
}, {}),
|
||||
});
|
||||
|
@ -74,6 +74,8 @@ export default {
|
||||
"Value":"Value",
|
||||
"Disabled":"Disabled",
|
||||
"Enabled":"Enabled",
|
||||
"Problematic": "Problematic",
|
||||
"Setting": "Setting",
|
||||
'On':'On',
|
||||
'Off':'Off',
|
||||
"Empty":"Empty",
|
||||
@ -653,6 +655,47 @@ export default {
|
||||
"Local": "Local",
|
||||
"Built-in": "Built-in",
|
||||
"Marketplace": "Marketplace",
|
||||
"Add plugin": "Add plugin",
|
||||
"Plugin source": "Plugin source",
|
||||
"Upgrade": "Upgrade",
|
||||
"Plugin dependencies check failed": "Plugin dependencies check failed",
|
||||
"More details": "More details",
|
||||
"Upload new version": "Upload new version",
|
||||
"Version": "Version",
|
||||
"Npm package": "Npm package",
|
||||
"Npm package name": "Npm package name",
|
||||
"Upload plugin": "Upload plugin",
|
||||
"Official plugin": "Official plugin",
|
||||
"Add type": "Add type",
|
||||
"Changelog": "Changelog",
|
||||
"Dependencies check": "Dependencies check",
|
||||
"Update plugin": "Update plugin",
|
||||
"Installing": "Installing",
|
||||
"The deletion was successful.": "The deletion was successful.",
|
||||
"Plugin Zip File": "Plugin Zip File",
|
||||
"Compressed file url": "Compressed file url",
|
||||
"Last updated": "Last updated",
|
||||
"PackageName": "PackageName",
|
||||
"DisplayName": "DisplayName",
|
||||
"Readme": "Readme",
|
||||
"Dependencies compatibility check": "Dependencies compatibility check",
|
||||
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "Plugin dependencies check failed, you should change the dependent version to meet the version requirements.",
|
||||
"Version range": "Version range",
|
||||
"Plugin's version": "Plugin's version",
|
||||
"Result": "Result",
|
||||
"No CHANGELOG.md file": "No CHANGELOG.md file",
|
||||
"No README.md file": "No README.md file",
|
||||
"Homepage": "Homepage",
|
||||
'Drag and drop the file here or click to upload, file size should not exceed 30M': 'Drag and drop the file here or click to upload, file size should not exceed 30M',
|
||||
"Dependencies check failed, can't enable.": "Dependencies check failed, can't enable.",
|
||||
"Plugin starting...": "Plugin starting...",
|
||||
"Plugin stopping...": "Plugin stopping...",
|
||||
"Are you sure to delete this plugin?": "Are you sure to delete this plugin?",
|
||||
"re-download file": "re-download file",
|
||||
"Not enabled": "Not enabled",
|
||||
"Search plugin": "Search plugin",
|
||||
"Author": "Author",
|
||||
"Plugin loading failed. Please check the server logs.": "Plugin loading failed. Please check the server logs.",
|
||||
"Coming soon...": "Coming soon...",
|
||||
"All plugin settings": "All plugin settings",
|
||||
"Bookmark": "Bookmark",
|
||||
|
@ -652,6 +652,46 @@ export default {
|
||||
"Local": "Local",
|
||||
"Built-in": "Integrado",
|
||||
"Marketplace": "Mercado",
|
||||
"New plugin": "Nuevo plugin",
|
||||
"Upgrade": "Actualizar",
|
||||
"Dependencies check failed": "Error en la comprobación de dependencias",
|
||||
"More details": "Más detalles",
|
||||
"Upload new version": "Subir nueva versión",
|
||||
"Official plugin": "Plugin oficial",
|
||||
"Version": "Versión",
|
||||
"Npm package": "Paquete Npm",
|
||||
"Upload plugin": "Subir plugin",
|
||||
"Npm package name": "Nombre del paquete Npm",
|
||||
"Add type": "Añadir tipo",
|
||||
"Changelog": "Registro de cambios",
|
||||
"Dependencies check": "Comprobación de dependencias",
|
||||
"Update plugin": "Actualizar plugin",
|
||||
"Installing": "Instalando",
|
||||
"The deletion was successful.": "Eliminación correcta",
|
||||
"Plugin Zip File": "Archivo Zip del plugin",
|
||||
"Compressed file url": "URL del archivo comprimido",
|
||||
"Last updated": "Última actualización",
|
||||
"PackageName": "Nombre del paquete",
|
||||
"DisplayName": "Nombre para mostrar",
|
||||
"Readme": "Leerme",
|
||||
"Dependencies compatibility check": "Comprobación de compatibilidad de dependencias",
|
||||
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Si la comprobación de compatibilidad falla, debe cambiar la versión dependiente para cumplir con los requisitos de versión.",
|
||||
"Version range": "Rango de versión",
|
||||
"Plugin's version": "Versión del plugin",
|
||||
"Result": "Resultado",
|
||||
"No CHANGELOG.md file": "No hay archivo CHANGELOG.md",
|
||||
"No README.md file": "No hay archivo README.md",
|
||||
"Homepage": "Página de inicio",
|
||||
'Drag and drop the file here or click to upload, file size should not exceed 30M': "Arrastra y suelta el archivo aquí o haz clic para subirlo, el tamaño del archivo no debe superar los 30M",
|
||||
"Dependencies check failed, can't enable.": "Error en la comprobación de dependencias, no se puede habilitar.",
|
||||
"Plugin starting...": "Plugin iniciando...",
|
||||
"Plugin stopping...": "Plugin deteniendo...",
|
||||
"Are you sure to delete this plugin?": "¿Estás seguro de eliminar este plugin?",
|
||||
"re-download file": "volver a descargar el archivo",
|
||||
"Not enabled": "No habilitado",
|
||||
"Search plugin": "Buscar plugin",
|
||||
"Author": "Autor",
|
||||
"Plugin loading failed. Please check the server logs.": "Error al cargar el plugin. Compruebe los registros del servidor.",
|
||||
"Coming soon...": "Próximamente...",
|
||||
"All plugin settings": "Configuración de todos los plugins",
|
||||
"Bookmark": "Marcador",
|
||||
|
@ -649,6 +649,46 @@ export default {
|
||||
"Local": "Local",
|
||||
"Built-in": "Intégré",
|
||||
"Marketplace": "Place de marché",
|
||||
"New plugin": "Nouveau plugin",
|
||||
"Upgrade": "Mise à jour",
|
||||
"Dependencies check failed": "Échec de la vérification des dépendances",
|
||||
"More details": "Plus de détails",
|
||||
"Upload new version": "Télécharger une nouvelle version",
|
||||
"Official plugin": "Plugin officiel",
|
||||
"Version": "Version",
|
||||
"Npm package": "Paquet Npm",
|
||||
"Upload plugin": "Télécharger un plugin",
|
||||
"Npm package name": "Nom du paquet Npm",
|
||||
"Add type": "Ajouter un type",
|
||||
"Changelog": "Journal des modifications",
|
||||
"Dependencies check": "Vérification des dépendances",
|
||||
"Update plugin": "Mettre à jour le plugin",
|
||||
"Installing": "Installation",
|
||||
"The deletion was successful.": "La suppression a réussi.",
|
||||
"Plugin Zip File": "Fichier Zip du plugin",
|
||||
"Compressed file url": "URL du fichier compressé",
|
||||
"Last updated": "Dernière mise à jour",
|
||||
"PackageName": "Nom du paquet",
|
||||
"DisplayName": "Nom d'affichage",
|
||||
"Readme": "Lisez-moi",
|
||||
"Dependencies compatibility check": "Vérification de la compatibilité des dépendances",
|
||||
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Si la vérification de la compatibilité échoue, vous devez modifier la version dépendante pour répondre aux exigences de version.",
|
||||
"Version range": "Plage de version",
|
||||
"Plugin's version": "Version du plugin",
|
||||
"Result": "Résultat",
|
||||
"No CHANGELOG.md file": "Aucun fichier CHANGELOG.md",
|
||||
"No README.md file": "Aucun fichier README.md",
|
||||
"Homepage": "Page d'accueil",
|
||||
'Drag and drop the file here or click to upload, file size should not exceed 30M': "Faites glisser et déposez le fichier ici ou cliquez pour télécharger, la taille du fichier ne doit pas dépasser 30M",
|
||||
"Dependencies check failed, can't enable.": "Échec de la vérification des dépendances, impossible d'activer.",
|
||||
"Plugin starting...": "Démarrage du plugin...",
|
||||
"Plugin stopping...": "Arrêt du plugin...",
|
||||
"Are you sure to delete this plugin?": "Êtes-vous sûr de vouloir supprimer ce plugin ?",
|
||||
"re-download file": "re-télécharger le fichier",
|
||||
"Not enabled": "Non activé",
|
||||
"Search plugin": "Rechercher un plugin",
|
||||
"Author": "Auteur",
|
||||
"Plugin loading failed. Please check the server logs.": "Échec du chargement du plugin. Veuillez vérifier les journaux du serveur.",
|
||||
"Coming soon...": "Bientôt...",
|
||||
"All plugin settings": "Tous les paramètres de plugin",
|
||||
"Bookmark": "Signet",
|
||||
|
@ -626,5 +626,45 @@ export default {
|
||||
"Tag color field":"ラベルの色フィールド",
|
||||
"Sync successfully":"同期成功",
|
||||
"Sync from form fields":"フォームフィールドの同期",
|
||||
"Select all":"すべて選択"
|
||||
"Select all": "すべて選択",
|
||||
"New plugin": "新しいプラグイン",
|
||||
"Upgrade": "アップグレード",
|
||||
"Dependencies check failed": "依存関係のチェックに失敗しました",
|
||||
"More details": "詳細",
|
||||
"Upload new version": "新しいバージョンをアップロード",
|
||||
"Version": "バージョン",
|
||||
"Npm package": "Npmパッケージ",
|
||||
"Npm package name": "Npmパッケージ名",
|
||||
"Upload plugin": "プラグインをアップロード",
|
||||
"Official plugin": "公式プラグイン",
|
||||
"Add type": "タイプを追加",
|
||||
"Changelog": "変更履歴",
|
||||
"Dependencies check": "依存関係のチェック",
|
||||
"Update plugin": "プラグインをアップグレード",
|
||||
"Installing": "インストール中",
|
||||
"The deletion was successful.": "削除に成功しました。",
|
||||
"Plugin Zip File": "プラグインZipファイル",
|
||||
"Compressed file url": "圧縮ファイルのURL",
|
||||
"Last updated": "最終更新",
|
||||
"PackageName": "パッケージ名",
|
||||
"DisplayName": "表示名",
|
||||
"Readme": "Readme",
|
||||
"Dependencies compatibility check": "依存関係の互換性チェック",
|
||||
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "互換性チェックに失敗した場合は、依存関係のバージョンを変更して、バージョン要件を満たす必要があります。",
|
||||
"Version range": "バージョン範囲",
|
||||
"Plugin's version": "プラグインのバージョン",
|
||||
"Result": "結果",
|
||||
"No CHANGELOG.md file": "CHANGELOG.mdファイルがありません",
|
||||
"No README.md file": "README.mdファイルがありません",
|
||||
"Homepage": "ホームページ",
|
||||
"Drag and drop the file here or click to upload, file size should not exceed 30M": "ファイルをここにドラッグ&ドロップするか、クリックしてアップロードしてください。ファイルサイズは10Mを超えてはいけません",
|
||||
"Dependencies check failed, can't enable.": "依存関係のチェックに失敗しました。有効にできません。",
|
||||
"Plugin starting...": "プラグインを起動しています...",
|
||||
"Plugin stopping...": "プラグインを停止しています...",
|
||||
"Are you sure to delete this plugin?": "このプラグインを削除してもよろしいですか?",
|
||||
"re-download file": "ファイルを再ダウンロード",
|
||||
"Not enabled": "有効になっていません",
|
||||
"Search plugin": "プラグインを検索",
|
||||
"Author": "著者",
|
||||
"Plugin loading failed. Please check the server logs.": "プラグインのロードに失敗しました。サーバーログを確認してください。",
|
||||
}
|
||||
|
@ -615,6 +615,46 @@ export default {
|
||||
"Local": "Local",
|
||||
"Built-in": "Integrado",
|
||||
"Marketplace": "Loja de aplicativos",
|
||||
"New plugin": "Novo plugin",
|
||||
"Upgrade": "Atualizar",
|
||||
"Dependencies check failed": "Falha na verificação de dependências",
|
||||
"More details": "Mais detalhes",
|
||||
"Upload new version": "Enviar nova versão",
|
||||
"Official plugin": "Plugin oficial",
|
||||
"Version": "Versão",
|
||||
"Npm package": "Pacote Npm",
|
||||
"Upload plugin": "Enviar plugin",
|
||||
"Npm package name": "Nome do pacote Npm",
|
||||
"Add type": "Adicionar tipo",
|
||||
"Changelog": "Registro de alterações",
|
||||
"Dependencies check": "Verificação de dependências",
|
||||
"Update plugin": "Atualizar plugin",
|
||||
"Installing": "Instalando",
|
||||
"The deletion was successful.": "A exclusão foi bem sucedida.",
|
||||
"Plugin Zip File": "Arquivo Zip do plugin",
|
||||
"Compressed file url": "URL do arquivo compactado",
|
||||
"Last updated": "Última atualização",
|
||||
"PackageName": "Nome do pacote",
|
||||
"DisplayName": "Nome de exibição",
|
||||
"Readme": "Leia-me",
|
||||
"Dependencies compatibility check": "Verificação de compatibilidade de dependências",
|
||||
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Se a verificação de compatibilidade falhar, você deve alterar a versão dependente para atender aos requisitos de versão.",
|
||||
"Version range": "Intervalo de versão",
|
||||
"Plugin's version": "Versão do plugin",
|
||||
"Result": "Resultado",
|
||||
"No CHANGELOG.md file": "Nenhum arquivo CHANGELOG.md",
|
||||
"No README.md file": "Nenhum arquivo README.md",
|
||||
"Homepage": "Página inicial",
|
||||
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Arraste e solte o arquivo aqui ou clique para enviar, o tamanho do arquivo não deve exceder 30M",
|
||||
"Dependencies check failed, can't enable.": "Falha na verificação de dependências, não é possível habilitar.",
|
||||
"Plugin starting...": "Plugin iniciando...",
|
||||
"Plugin stopping...": "Plugin parando...",
|
||||
"Are you sure to delete this plugin?": "Tem certeza de que deseja excluir este plugin?",
|
||||
"re-download file": "re-fazer download do arquivo",
|
||||
"Not enabled": "Não habilitado",
|
||||
"Search plugin": "Pesquisar plugin",
|
||||
"Author": "Autor",
|
||||
"Plugin loading failed. Please check the server logs.": "Falha ao carregar o plugin. Verifique os logs do servidor.",
|
||||
"Coming soon...": "Em breve...",
|
||||
"All plugin settings": "Configurações de todos os plugins",
|
||||
"Bookmark": "Favoritar",
|
||||
|
@ -505,5 +505,46 @@ export default {
|
||||
'Form data templates': "Шаблоны данных формы",
|
||||
"Data template": "Шаблон данных",
|
||||
"Not found":"Не найдено",
|
||||
"Add":"Добавить"
|
||||
"Add": "Добавить",
|
||||
"Select all": "Выбрать все",
|
||||
"New plugin": "Новый плагин",
|
||||
"Upgrade": "Обновить",
|
||||
"Dependencies check failed": "Проверка зависимостей не удалась",
|
||||
"More details": "Подробнее",
|
||||
"Upload new version": "Загрузить новую версию",
|
||||
"Version": "Версия",
|
||||
"Npm package": "Npm пакет",
|
||||
"Npm package name": "Имя npm пакета",
|
||||
"Upload plugin": "Загрузить плагин",
|
||||
"Official plugin": "Официальный плагин",
|
||||
"Add type": "Добавить тип",
|
||||
"Changelog": "Изменения",
|
||||
"Dependencies check": "Проверка зависимостей",
|
||||
"Update plugin": "Обновить плагин",
|
||||
"Installing": "Установка",
|
||||
"The deletion was successful.": "Удаление прошло успешно.",
|
||||
"Plugin Zip File": "Zip файл плагина",
|
||||
"Compressed file url": "URL сжатого файла",
|
||||
"Last updated": "Последнее обновление",
|
||||
"PackageName": "Имя пакета",
|
||||
"DisplayName": "Отображаемое имя",
|
||||
"Readme": "Инструкция",
|
||||
"Dependencies compatibility check": "Проверка совместимости зависимостей",
|
||||
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "Если проверка совместимости зависимостей не удалась, вы должны изменить версию зависимости, чтобы соответствовать требованиям версии.",
|
||||
"Version range": "Диапазон версий",
|
||||
"Plugin's version": "Версия плагина",
|
||||
"Result": "Результат",
|
||||
"No CHANGELOG.md file": "Нет файла CHANGELOG.md",
|
||||
"No README.md file": "Нет файла README.md",
|
||||
"Homepage": "Домашняя страница",
|
||||
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Перетащите файл сюда или нажмите, чтобы загрузить, размер файла не должен превышать 30M",
|
||||
"Dependencies check failed, can't enable.": "Проверка зависимостей не удалась, невозможно включить.",
|
||||
"Plugin starting...": "Запуск плагина...",
|
||||
"Plugin stopping...": "Остановка плагина...",
|
||||
"Are you sure to delete this plugin?": "Вы уверены, что хотите удалить этот плагин?",
|
||||
"re-download file": "повторно загрузить файл",
|
||||
"Not enabled": "Не включено",
|
||||
"Search plugin": "Поиск плагина",
|
||||
"Author": "Автор",
|
||||
"Plugin loading failed. Please check the server logs.": "Не удалось загрузить плагин. Пожалуйста, проверьте журналы сервера.",
|
||||
}
|
||||
|
@ -504,4 +504,44 @@ export default {
|
||||
'Display data template selector': 'Veri şablonu seçicisini görüntüle',
|
||||
'Form data templates': 'Form veri şablonları',
|
||||
"Data template": "Veri şablonu",
|
||||
"New plugin": "Yeni eklenti",
|
||||
"Upgrade": "Yükselt",
|
||||
"Dependencies check failed": "Bağımlılıkların kontrolü başarısız oldu",
|
||||
"More details": "Daha fazla detay",
|
||||
"Upload new version": "Yeni sürüm yükle",
|
||||
"Version": "Sürüm",
|
||||
"Npm package": "Npm paketi",
|
||||
"Npm package name": "Npm paket adı",
|
||||
"Upload plugin": "Eklenti yükle",
|
||||
"Official plugin": "Resmi eklenti",
|
||||
"Add type": "Tür ekle",
|
||||
"Changelog": "Değişiklik günlüğü",
|
||||
"Dependencies check": "Bağımlılıkların kontrolü",
|
||||
"Update plugin": "Eklentiyi yükselt",
|
||||
"Installing": "Yükleniyor",
|
||||
"The deletion was successful.": "Silme başarılı.",
|
||||
"Plugin Zip File": "Eklenti Zip Dosyası",
|
||||
"Compressed file url": "Sıkıştırılmış dosya bağlantısı",
|
||||
"Last updated": "Son güncelleme",
|
||||
"PackageName": "Paket Adı",
|
||||
"DisplayName": "Görünen Ad",
|
||||
"Readme": "Okuma Dosyası",
|
||||
"Dependencies compatibility check": "Bağımlılık uyumluluğu kontrolü",
|
||||
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Uyumluluk kontrolü başarısız olursa, bağımlı olan sürümü değiştirmeniz gerekmektedir.",
|
||||
"Version range": "Sürüm aralığı",
|
||||
"Plugin's version": "Eklentinin sürümü",
|
||||
"Result": "Sonuç",
|
||||
"No CHANGELOG.md file": "CHANGELOG.md dosyası bulunmamaktadır",
|
||||
"No README.md file": "README.md dosyası bulunmamaktadır",
|
||||
"Homepage": "Anasayfa",
|
||||
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Dosyayı buraya sürükleyin veya yüklemek için tıklayın, dosya boyutu 30M'i geçmemelidir",
|
||||
"Dependencies check failed, can't enable.": "Bağımlılık kontrolü başarısız oldu, etkinleştirilemiyor.",
|
||||
"Plugin starting...": "Eklenti başlatılıyor...",
|
||||
"Plugin stopping...": "Eklenti durduruluyor...",
|
||||
"Are you sure to delete this plugin?": "Bu eklentiyi silmek istediğinizden emin misiniz?",
|
||||
"re-download file": "dosyayı yeniden indir",
|
||||
"Not enabled": "Etkin değil",
|
||||
"Search plugin": "Eklenti ara",
|
||||
"Author": "Yazar",
|
||||
"Plugin loading failed. Please check the server logs.": "Eklenti yüklenemedi. Lütfen sunucu günlüklerini kontrol edin.",
|
||||
}
|
||||
|
@ -669,6 +669,46 @@ export default {
|
||||
"Local": "Локальний",
|
||||
"Built-in": "Вбудований",
|
||||
"Marketplace": "Маркетплейс",
|
||||
"New plugin": "Новий плагін",
|
||||
"Upgrade": "Оновити",
|
||||
"Dependencies check failed": "Перевірка залежностей не вдалася",
|
||||
"More details": "Детальніше",
|
||||
"Upload new version": "Завантажити нову версію",
|
||||
"Official plugin": "Офіційний плагін",
|
||||
"Version": "Версія",
|
||||
"Npm package": "Npm пакет",
|
||||
"Upload plugin": "Завантажити плагін",
|
||||
"Npm package name": "Ім'я пакета npm",
|
||||
"Add type": "Додати тип",
|
||||
"Changelog": "Журнал змін",
|
||||
"Dependencies check": "Перевірка залежностей",
|
||||
"Update plugin": "Оновити плагін",
|
||||
"Installing": "Встановлення",
|
||||
"The deletion was successful.": "Видалення пройшло успішно.",
|
||||
"Plugin Zip File": "Файл плагіна Zip",
|
||||
"Compressed file url": "URL стислого файлу",
|
||||
"Last updated": "Останнє оновлення",
|
||||
"PackageName": "Назва пакунка",
|
||||
"DisplayName": "Відображуване ім'я",
|
||||
"Readme": "Інструкція",
|
||||
"Dependencies compatibility check": "Перевірка сумісності залежностей",
|
||||
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "Якщо перевірка сумісності не вдається, вам слід змінити залежну версію, щоб вона відповідала вимогам до версії.",
|
||||
"Version range": "Діапазон версій",
|
||||
"Plugin's version": "Версія плагіна",
|
||||
"Result": "Результат",
|
||||
"No CHANGELOG.md file": "Файл CHANGELOG.md відсутній",
|
||||
"No README.md file": "Файл README.md відсутній",
|
||||
"Homepage": "Домашня сторінка",
|
||||
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Перетягніть файл сюди або натисніть, щоб завантажити, розмір файлу не повинен перевищувати 10 Мб",
|
||||
"Dependencies check failed, can't enable.": "Перевірка залежностей не вдалась, неможливо увімкнути.",
|
||||
"Plugin starting...": "Запуск плагіна...",
|
||||
"Plugin stopping...": "Зупинка плагіна...",
|
||||
"Are you sure to delete this plugin?": "Ви впевнені, що хочете видалити цей плагін?",
|
||||
"re-download file": "перезавантажити файл",
|
||||
"Not enabled": "Не увімкнено",
|
||||
"Search plugin": "Пошук плагіна",
|
||||
"Author": "Автор",
|
||||
"Plugin loading failed. Please check the server logs.": "Не вдалося завантажити плагін. Будь ласка, перевірте журнали сервера.",
|
||||
"Coming soon...": "Скоро буде...",
|
||||
"All plugin settings": "Всі налаштування плагінів",
|
||||
"Bookmark": "Закладка",
|
||||
|
@ -80,6 +80,7 @@ export default {
|
||||
Value: '字段值',
|
||||
Disabled: '禁用',
|
||||
Enabled: '启用',
|
||||
Problematic: '有问题',
|
||||
Empty: '赋空值',
|
||||
'Linkage rule': '联动规则',
|
||||
'Linkage rules': '联动规则',
|
||||
@ -708,7 +709,50 @@ export default {
|
||||
'Plugin manager': '插件管理器',
|
||||
Local: '本地',
|
||||
'Built-in': '内置',
|
||||
Marketplace: '插件市场',
|
||||
'Marketplace': '插件市场',
|
||||
"Add plugin": "新增插件",
|
||||
"Upgrade": "可供更新",
|
||||
"Plugin dependencies check failed": "插件依赖检查失败",
|
||||
"Remove": "移除",
|
||||
"Docs": "文档",
|
||||
"More details": "更多详情",
|
||||
"Upload new version": "上传新版",
|
||||
"Official plugin": "官方插件",
|
||||
"Version": "版本",
|
||||
"Npm package": "Npm 包",
|
||||
"Upload plugin": "上传插件",
|
||||
"Npm package name": "Npm 包名",
|
||||
"Add type": "新增方式",
|
||||
"Plugin source": "插件来源",
|
||||
"Changelog": "更新日志",
|
||||
"Dependencies check": "依赖检查",
|
||||
"Update plugin": "更新插件",
|
||||
"Installing": "安装中",
|
||||
"The deletion was successful.": "删除成功",
|
||||
"Plugin Zip File": "插件压缩包",
|
||||
"Compressed file url": "压缩包地址",
|
||||
"Last updated": "最后更新",
|
||||
"PackageName": "包名",
|
||||
"DisplayName": "显示名称",
|
||||
"Readme": "说明文档",
|
||||
"Dependencies compatibility check": "依赖兼容性检查",
|
||||
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "插件兼容性检查失败,你应该修改依赖版本以满足版本要求。",
|
||||
"Version range": "版本范围",
|
||||
"Plugin's version": "插件的版本",
|
||||
"Result": "结果",
|
||||
"No CHANGELOG.md file": "没有 CHANGELOG.md 日志",
|
||||
"No README.md file": "没有 README.md 文件",
|
||||
"Homepage": "主页",
|
||||
'Drag and drop the file here or click to upload, file size should not exceed 30M': '将文件拖放到此处或单击上传,文件大小不应超过 30M',
|
||||
"Dependencies check failed, can't enable.": "依赖检查失败,无法启用。",
|
||||
"Plugin starting...": "插件启动中...",
|
||||
"Plugin stopping...": "插件停止中...",
|
||||
"Are you sure to delete this plugin?": "确定要删除此插件吗?",
|
||||
"re-download file": "重新下载文件",
|
||||
"Not enabled": "未启用",
|
||||
"Search plugin": "搜索插件",
|
||||
"Author": "作者",
|
||||
"Plugin loading failed. Please check the server logs.": "插件加载失败,请检查服务器日志。",
|
||||
'Coming soon...': '敬请期待...',
|
||||
'All plugin settings': '所有插件配置',
|
||||
Bookmark: '书签',
|
||||
|
@ -132,12 +132,21 @@ const getProps = (app: Application) => {
|
||||
upgrade: {
|
||||
title: 'App upgrading',
|
||||
},
|
||||
'pm.add': {
|
||||
title: 'Adding plugin',
|
||||
},
|
||||
'pm.update': {
|
||||
title: 'Updating plugin',
|
||||
},
|
||||
'pm.enable': {
|
||||
title: 'Enabling plugin',
|
||||
},
|
||||
'pm.disable': {
|
||||
title: 'Disabling plugin',
|
||||
},
|
||||
'pm.remove': {
|
||||
title: 'Removing plugin',
|
||||
},
|
||||
};
|
||||
return { ...props, ...commands[app.error?.command?.name] };
|
||||
}
|
||||
|
@ -1,359 +0,0 @@
|
||||
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
App,
|
||||
Avatar,
|
||||
Card,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsProps,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { IPluginData } from '.';
|
||||
import { useAPIClient, useRequest } from '../api-client';
|
||||
import { useGlobalTheme } from '../global-theme';
|
||||
import { useStyles as useMarkdownStyles } from '../schema-component/antd/markdown/style';
|
||||
import { useParseMarkdown } from '../schema-component/antd/markdown/util';
|
||||
import { useStyles } from './style';
|
||||
|
||||
interface PluginDocumentProps {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ICommonCard {
|
||||
onClick: () => void;
|
||||
name: string;
|
||||
description: string;
|
||||
title: string;
|
||||
displayName: string;
|
||||
actions?: JSX.Element[];
|
||||
}
|
||||
|
||||
interface IPluginDetail {
|
||||
plugin: any;
|
||||
onCancel: () => void;
|
||||
items: TabsProps['items'];
|
||||
}
|
||||
|
||||
/**
|
||||
* get color by string
|
||||
* TODO: real avatar
|
||||
* @param str
|
||||
*/
|
||||
const stringToColor = function (str: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let color = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += ('00' + value.toString(16)).substr(-2);
|
||||
}
|
||||
return color;
|
||||
};
|
||||
|
||||
const PluginDocument: React.FC<PluginDocumentProps> = (props) => {
|
||||
const { styles } = useStyles();
|
||||
const { isDarkTheme } = useGlobalTheme();
|
||||
const { componentCls, hashId } = useMarkdownStyles({ isDarkTheme });
|
||||
const [docLang, setDocLang] = useState('');
|
||||
const { name, path } = props;
|
||||
const { data, loading, error } = useRequest<{
|
||||
data: {
|
||||
content: string;
|
||||
};
|
||||
}>(
|
||||
{
|
||||
url: '/plugins:getTabInfo',
|
||||
params: {
|
||||
filterByTk: name,
|
||||
path: path,
|
||||
locale: docLang,
|
||||
},
|
||||
},
|
||||
{
|
||||
refreshDeps: [name, path, docLang],
|
||||
},
|
||||
);
|
||||
const { html, loading: parseLoading } = useParseMarkdown(data?.data?.content);
|
||||
|
||||
const htmlWithOutRelativeDirect = useMemo(() => {
|
||||
if (html) {
|
||||
const pattern = /<a\s+href="\..*?\/([^/]+)"/g;
|
||||
return html.replace(pattern, (match, $1) => match + `onclick="return false;"`); // prevent the default event of <a/>
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
const handleSwitchDocLang = useCallback((e: MouseEvent) => {
|
||||
const lang = (e.target as HTMLDivElement).innerHTML;
|
||||
if (lang.trim() === '中文') {
|
||||
setDocLang('zh-CN');
|
||||
} else if (lang.trim() === 'English') {
|
||||
setDocLang('en-US');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const md = document.getElementById('pm-md-preview');
|
||||
md.addEventListener('click', handleSwitchDocLang);
|
||||
return () => {
|
||||
removeEventListener('click', handleSwitchDocLang);
|
||||
};
|
||||
}, [handleSwitchDocLang]);
|
||||
|
||||
return (
|
||||
<div className={styles.PluginDocument} id="pm-md-preview">
|
||||
{loading || parseLoading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<div
|
||||
className={`${componentCls} ${hashId} nb-markdown nb-markdown-default nb-markdown-table`}
|
||||
dangerouslySetInnerHTML={{ __html: error ? '' : htmlWithOutRelativeDirect }}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function PluginDetail(props: IPluginDetail) {
|
||||
const { plugin, onCancel, items } = props;
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={false}
|
||||
className={styles.PluginDetail}
|
||||
width="70%"
|
||||
title={
|
||||
<Typography.Title level={2} style={{ margin: 0 }}>
|
||||
{plugin?.displayName || plugin?.name}
|
||||
<Tag className={'version-tag'}>v{plugin?.version}</Tag>
|
||||
</Typography.Title>
|
||||
}
|
||||
open={!!plugin}
|
||||
onCancel={onCancel}
|
||||
destroyOnClose
|
||||
>
|
||||
{plugin?.description && <div className={'plugin-desc'}>{plugin?.description}</div>}
|
||||
<Tabs items={items} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CommonCard(props: ICommonCard) {
|
||||
const { onClick, name, displayName, actions, description, title } = props;
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<Card
|
||||
bordered={false}
|
||||
className={styles.CommonCard}
|
||||
onClick={onClick}
|
||||
hoverable
|
||||
// className={cls(css`
|
||||
// &:hover {
|
||||
// border: 1px solid var(--antd-wave-shadow-color);
|
||||
// cursor: pointer;
|
||||
// }
|
||||
|
||||
// border: 1px solid transparent;
|
||||
// `)}
|
||||
actions={actions}
|
||||
// actions={[<a>Settings</a>, <a>Remove</a>, <Switch size={'small'} defaultChecked={true}></Switch>]}
|
||||
>
|
||||
<Card.Meta
|
||||
className={styles.avatar}
|
||||
avatar={<Avatar style={{ background: `${stringToColor(name)}` }}>{name?.[0]}</Avatar>}
|
||||
description={
|
||||
<Tooltip title={description} placement="bottom">
|
||||
<div
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{description || '-'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
title={
|
||||
<span>
|
||||
{displayName || name}
|
||||
<span className={styles.version}>{title}</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const PluginCard = (props: { data: IPluginData }) => {
|
||||
const navigate = useNavigate();
|
||||
const { data } = props;
|
||||
const api = useAPIClient();
|
||||
const { t } = useTranslation();
|
||||
const { enabled, name, displayName, id, description, version } = data;
|
||||
const [plugin, setPlugin] = useState<any>(null);
|
||||
const { modal } = App.useApp();
|
||||
const { data: tabsData, run } = useRequest<any>(
|
||||
{
|
||||
url: '/plugins:getTabs',
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
},
|
||||
);
|
||||
const items = useMemo<TabsProps['items']>(() => {
|
||||
return tabsData?.data?.tabs.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
key: item.path,
|
||||
children: React.createElement(PluginDocument, {
|
||||
name: tabsData?.data.filterByTk,
|
||||
path: item.path,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, [tabsData?.data]);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
[
|
||||
enabled ? (
|
||||
<SettingOutlined
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/admin/settings/${name}`);
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
<Popconfirm
|
||||
key={id}
|
||||
title={t('Are you sure to delete this plugin?')}
|
||||
onConfirm={async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.request({
|
||||
url: `pm:remove/${name}`,
|
||||
});
|
||||
message.success(t('插件删除成功'));
|
||||
// window.location.reload();
|
||||
}}
|
||||
onCancel={(e) => e.stopPropagation()}
|
||||
okText={t('Yes')}
|
||||
cancelText={t('No')}
|
||||
>
|
||||
<DeleteOutlined onClick={(e) => e.stopPropagation()} />
|
||||
</Popconfirm>,
|
||||
<Switch
|
||||
key={id}
|
||||
size={'small'}
|
||||
onChange={async (checked, e) => {
|
||||
e.stopPropagation();
|
||||
await api.request({
|
||||
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
|
||||
});
|
||||
}}
|
||||
checked={enabled}
|
||||
></Switch>,
|
||||
].filter(Boolean),
|
||||
[api, enabled, navigate, id, name, t],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<PluginDetail plugin={plugin} onCancel={() => setPlugin(null)} items={items} />
|
||||
<CommonCard
|
||||
onClick={() => {
|
||||
setPlugin(data);
|
||||
run({
|
||||
params: {
|
||||
filterByTk: name,
|
||||
},
|
||||
});
|
||||
}}
|
||||
name={name}
|
||||
description={description}
|
||||
title={version}
|
||||
actions={actions}
|
||||
displayName={displayName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BuiltInPluginCard = (props: { data: IPluginData }) => {
|
||||
const {
|
||||
data: { description, name, version, displayName },
|
||||
data,
|
||||
} = props;
|
||||
const navigate = useNavigate();
|
||||
const [plugin, setPlugin] = useState<any>(null);
|
||||
const { data: tabsData, run } = useRequest<{
|
||||
data: {
|
||||
tabs: {
|
||||
title: string;
|
||||
path: string;
|
||||
}[];
|
||||
filterByTk: string;
|
||||
};
|
||||
}>(
|
||||
{
|
||||
url: '/plugins:getTabs',
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
},
|
||||
);
|
||||
const items = useMemo(() => {
|
||||
return tabsData?.data?.tabs.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
key: item.path,
|
||||
children: React.createElement(PluginDocument, {
|
||||
name: tabsData?.data.filterByTk,
|
||||
path: item.path,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, [tabsData?.data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginDetail plugin={plugin} onCancel={() => setPlugin(null)} items={items} />
|
||||
<CommonCard
|
||||
onClick={() => {
|
||||
setPlugin(data);
|
||||
run({
|
||||
params: {
|
||||
filterByTk: name,
|
||||
},
|
||||
});
|
||||
}}
|
||||
name={name}
|
||||
displayName={displayName}
|
||||
description={description}
|
||||
title={version}
|
||||
actions={[
|
||||
<div key="placeholder-comp"></div>,
|
||||
<SettingOutlined
|
||||
key="settings"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/admin/settings/${name}`);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
219
packages/core/client/src/pm/PluginCard.tsx
Normal file
219
packages/core/client/src/pm/PluginCard.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import { App, Card, Divider, Popconfirm, Space, Switch, Typography, message } from 'antd';
|
||||
import classnames from 'classnames';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { DeleteOutlined, ReadOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { css } from '@emotion/css';
|
||||
import { useAPIClient } from '../api-client';
|
||||
import { PluginDetail } from './PluginDetail';
|
||||
import { PluginUpgradeModal } from './PluginForm/modal/PluginUpgradeModal';
|
||||
import { useStyles } from './style';
|
||||
import type { IPluginData } from './types';
|
||||
|
||||
interface IPluginInfo extends IPluginCard {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function PluginInfo(props: IPluginInfo) {
|
||||
const { data, onClick } = props;
|
||||
const { name, displayName, isCompatible, packageName, updatable, builtIn, enabled, description, type, error } = data;
|
||||
const { styles, theme } = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const api = useAPIClient();
|
||||
const { modal } = App.useApp();
|
||||
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||
const [enabledVal, setEnabledVal] = useState(enabled);
|
||||
const reload = () => window.location.reload();
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUploadForm && (
|
||||
<PluginUpgradeModal
|
||||
isShow={showUploadForm}
|
||||
pluginData={data}
|
||||
onClose={(isRefresh) => {
|
||||
setShowUploadForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
size={'small'}
|
||||
bordered={false}
|
||||
onClick={() => {
|
||||
!error && onClick();
|
||||
}}
|
||||
headStyle={{ border: 'none', minHeight: 'inherit', paddingTop: 14 }}
|
||||
bodyStyle={{ paddingTop: 10 }}
|
||||
// style={{ marginBottom: theme.marginLG }}
|
||||
title={<div>{displayName || name || packageName}</div>}
|
||||
hoverable
|
||||
className={css`
|
||||
.ant-card-actions {
|
||||
li .ant-space {
|
||||
gap: 2px !important;
|
||||
}
|
||||
li a {
|
||||
.anticon {
|
||||
margin-right: 3px;
|
||||
/* display: none; */
|
||||
}
|
||||
}
|
||||
li:last-child {
|
||||
width: 20% !important;
|
||||
}
|
||||
li:first-child {
|
||||
width: 80% !important;
|
||||
border-inline-end: 0;
|
||||
text-align: left;
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
`}
|
||||
actions={[
|
||||
<Space split={<Divider type="vertical" />} key={'1'}>
|
||||
<a key={'5'}>
|
||||
<ReadOutlined /> {t('Docs')}
|
||||
</a>
|
||||
{updatable && (
|
||||
<a
|
||||
key={'3'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadForm(true);
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined /> {t('Update')}
|
||||
</a>
|
||||
)}
|
||||
{enabled ? (
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/admin/settings/${name}`);
|
||||
}}
|
||||
>
|
||||
<SettingOutlined /> {t('Setting')}
|
||||
</a>
|
||||
) : (
|
||||
<Popconfirm
|
||||
key={'delete'}
|
||||
disabled={builtIn}
|
||||
title={t('Are you sure to delete this plugin?')}
|
||||
onConfirm={async (e) => {
|
||||
e.stopPropagation();
|
||||
api.request({
|
||||
url: `pm:remove`,
|
||||
params: {
|
||||
filterByTk: name,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCancel={(e) => e.stopPropagation()}
|
||||
okText={t('Yes')}
|
||||
cancelText={t('No')}
|
||||
>
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={classnames({ [styles.cardActionDisabled]: builtIn })}
|
||||
>
|
||||
<DeleteOutlined /> {t('Remove')}
|
||||
</a>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>,
|
||||
<Switch
|
||||
key={'enable'}
|
||||
size={'small'}
|
||||
disabled={builtIn || error}
|
||||
onChange={async (checked, e) => {
|
||||
e.stopPropagation();
|
||||
if (!isCompatible && checked) {
|
||||
message.error(t("Dependencies check failed, can't enable."));
|
||||
return;
|
||||
}
|
||||
await api.request({
|
||||
url: `pm:${checked ? 'enable' : 'disable'}`,
|
||||
params: {
|
||||
filterByTk: name,
|
||||
},
|
||||
});
|
||||
}}
|
||||
checked={enabledVal}
|
||||
></Switch>,
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<Card.Meta
|
||||
description={
|
||||
!error ? (
|
||||
<Typography.Paragraph
|
||||
style={{ height: theme.fontSize * theme.lineHeight * 3 }}
|
||||
type={isCompatible ? 'secondary' : 'danger'}
|
||||
ellipsis={{ rows: 3 }}
|
||||
>
|
||||
{isCompatible ? description : t('Plugin dependencies check failed')}
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<Typography.Text type="danger">
|
||||
{t('Plugin loading failed. Please check the server logs.')}
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* {!isCompatible && !error && (
|
||||
<Button style={{ padding: 0 }} type="link">
|
||||
<Typography.Text type="danger">{t('Dependencies check failed')}</Typography.Text>
|
||||
</Button>
|
||||
)} */}
|
||||
{/*
|
||||
<Col span={8}>
|
||||
<Space direction="vertical" align="end" style={{ display: 'flex', marginTop: -10 }}>
|
||||
{type && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadForm(true);
|
||||
}}
|
||||
ghost
|
||||
type="primary"
|
||||
>
|
||||
{t('Update plugin')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<Button style={{ padding: 0 }} type="link">
|
||||
{t('More details')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Col> */}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface IPluginCard {
|
||||
data: IPluginData;
|
||||
}
|
||||
|
||||
export const PluginCard: FC<IPluginCard> = (props) => {
|
||||
const { data } = props;
|
||||
const [plugin, setPlugin] = useState<IPluginData>(undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
{plugin && <PluginDetail plugin={plugin} onCancel={() => setPlugin(undefined)} />}
|
||||
<PluginInfo
|
||||
onClick={() => {
|
||||
setPlugin(data);
|
||||
}}
|
||||
data={data}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
265
packages/core/client/src/pm/PluginDetail.tsx
Normal file
265
packages/core/client/src/pm/PluginDetail.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { Alert, Col, Modal, Row, Space, Spin, Table, Tabs, TabsProps, Tag, Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRequest } from '../api-client';
|
||||
import { PluginDocument } from './PluginDocument';
|
||||
import { useStyles } from './style';
|
||||
import { IPluginData } from './types';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
type Author =
|
||||
| string
|
||||
| {
|
||||
name: string;
|
||||
email?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
interface PackageJSON {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
repository?: string | { type: string; url: string };
|
||||
homepage?: string;
|
||||
license?: string;
|
||||
author?: Author;
|
||||
devDependencies?: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DepCompatible {
|
||||
name: string;
|
||||
result: boolean;
|
||||
versionRange: string;
|
||||
packageVersion: string;
|
||||
}
|
||||
|
||||
interface IPluginDetailData {
|
||||
packageJson: PackageJSON;
|
||||
depsCompatible: DepCompatible[] | false;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface IPluginDetail {
|
||||
plugin: IPluginData;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const PluginDetail: FC<IPluginDetail> = ({ plugin, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const dependenciesCompatibleTableColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('Name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: t('Version range'),
|
||||
dataIndex: 'versionRange',
|
||||
key: 'versionRange',
|
||||
},
|
||||
{
|
||||
title: t("Plugin's version"),
|
||||
dataIndex: 'packageVersion',
|
||||
key: 'packageVersion',
|
||||
},
|
||||
{
|
||||
title: t('Result'),
|
||||
dataIndex: 'result',
|
||||
key: 'result',
|
||||
render: (result: boolean) => <Tag color={result ? 'success' : 'error'}>{result ? 'Yes' : 'No'}</Tag>,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const { data, loading } = useRequest<{ data: IPluginDetailData }>(
|
||||
{
|
||||
url: `pm:get`,
|
||||
params: {
|
||||
filterByTk: plugin.name,
|
||||
},
|
||||
},
|
||||
{
|
||||
refreshDeps: [plugin.name],
|
||||
ready: !!plugin.name,
|
||||
},
|
||||
);
|
||||
|
||||
const repository = useMemo(() => {
|
||||
if (!data?.data?.packageJson?.repository) return null;
|
||||
const repository = data?.data?.packageJson.repository;
|
||||
const url = typeof repository === 'string' ? repository : repository.url;
|
||||
return url.replace(/\.git$/, '').replace(/^git\+/, '');
|
||||
}, [data]);
|
||||
|
||||
const author = useMemo(() => {
|
||||
const author = data?.data?.packageJson.author;
|
||||
if (!author) return null;
|
||||
if (typeof author === 'string') return author;
|
||||
return author.name;
|
||||
}, [data]);
|
||||
|
||||
const { styles, theme } = useStyles();
|
||||
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{
|
||||
key: 'readme',
|
||||
label: t('Readme'),
|
||||
children: (
|
||||
<Row gutter={20}>
|
||||
<Col span={16}>
|
||||
{plugin?.readmeUrl ? (
|
||||
<PluginDocument url={plugin?.readmeUrl} packageName={plugin.packageName} />
|
||||
) : (
|
||||
t('No README.md file')
|
||||
)}
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Row>
|
||||
{plugin.name && (
|
||||
<Col span={12}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('Name')}</Typography.Text>
|
||||
<Typography.Text strong>{plugin.name}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{plugin.displayName && (
|
||||
<Col span={12}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('DisplayName')}</Typography.Text>
|
||||
<Typography.Text strong>{plugin.displayName}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={24}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('PackageName')}</Typography.Text>
|
||||
<Typography.Text strong>{plugin.packageName}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
{repository && (
|
||||
<Col span={24}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('Repository')}</Typography.Text>
|
||||
<Typography.Text strong>{repository}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{data?.data?.packageJson.homepage && (
|
||||
<Col span={24}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('Homepage')}</Typography.Text>
|
||||
<Typography.Text strong>{data?.data?.packageJson.homepage}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{plugin.description && (
|
||||
<Col span={24}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('Description')}</Typography.Text>
|
||||
<Typography.Text strong>{plugin.description}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{data?.data?.packageJson.license && (
|
||||
<Col span={12}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('License')}</Typography.Text>
|
||||
<Typography.Text strong>{data?.data?.packageJson.license}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{author && (
|
||||
<Col span={12}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('Author')}</Typography.Text>
|
||||
<Typography.Text strong>{author}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={12}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('Last updated')}</Typography.Text>
|
||||
<Typography.Text strong>{dayjs(data?.data?.lastUpdated).fromNow()}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div className={styles.PluginDetailBaseInfo}>
|
||||
<Typography.Text type="secondary">{t('Version')}</Typography.Text>
|
||||
<Typography.Text strong>{plugin?.version}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dependencies',
|
||||
label: t('Dependencies compatibility check'),
|
||||
children: (
|
||||
<>
|
||||
{data?.data?.depsCompatible === false ? (
|
||||
<Typography.Text type="danger">
|
||||
{t('`dist/externalVersion.js` not found or failed to `require`. Please rebuild this plugin.')}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
{!data?.data?.['isCompatible'] && (
|
||||
<Alert
|
||||
showIcon
|
||||
type={'error'}
|
||||
message={t(
|
||||
'Plugin dependencies check failed, you should change the dependent version to meet the version requirements.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
style={{ marginTop: theme.margin }}
|
||||
rowKey={'name'}
|
||||
pagination={false}
|
||||
columns={dependenciesCompatibleTableColumns}
|
||||
dataSource={data?.data?.depsCompatible}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'changelog',
|
||||
label: t('Changelog'),
|
||||
children: plugin?.changelogUrl ? <PluginDocument url={plugin?.changelogUrl} /> : t('No CHANGELOG.md file'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal open={!!plugin} footer={false} destroyOnClose width={1200} onCancel={onCancel}>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
plugin && (
|
||||
<>
|
||||
<Typography.Title level={3}>{plugin.packageName}</Typography.Title>
|
||||
<Space split={<span> • </span>}>
|
||||
<span>{plugin.version}</span>
|
||||
<span>
|
||||
{t('Last updated')} {dayjs(data?.data?.lastUpdated).fromNow()}
|
||||
</span>
|
||||
</Space>
|
||||
<Tabs
|
||||
style={{ minHeight: '50vh' }}
|
||||
items={tabItems}
|
||||
defaultActiveKey={!plugin.isCompatible ? 'dependencies' : undefined}
|
||||
></Tabs>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
75
packages/core/client/src/pm/PluginDocument.tsx
Normal file
75
packages/core/client/src/pm/PluginDocument.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { Spin } from 'antd';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useRequest } from '../api-client';
|
||||
import { useStyles as useMarkdownStyles } from '../schema-component/antd/markdown/style';
|
||||
import { useParseMarkdown } from '../schema-component/antd/markdown/util';
|
||||
import { useStyles } from './style';
|
||||
import { useGlobalTheme } from '../global-theme';
|
||||
|
||||
const PLUGIN_STATICS_PATH = '/static/plugins/';
|
||||
|
||||
interface PluginDocumentProps {
|
||||
url: string;
|
||||
packageName?: string;
|
||||
}
|
||||
|
||||
export const PluginDocument: React.FC<PluginDocumentProps> = memo((props) => {
|
||||
const { isDarkTheme } = useGlobalTheme();
|
||||
const { componentCls, hashId } = useMarkdownStyles({ isDarkTheme });
|
||||
const { styles } = useStyles();
|
||||
const { url, packageName } = props;
|
||||
const [docUrl, setDocUrl] = useState(url);
|
||||
const { data, loading, error } = useRequest<string>(
|
||||
{ url: docUrl, baseURL: '/' },
|
||||
{
|
||||
refreshDeps: [docUrl],
|
||||
},
|
||||
);
|
||||
const { html, loading: parseLoading } = useParseMarkdown(data);
|
||||
|
||||
const htmlWithOutRelativeDirect = useMemo(() => {
|
||||
if (html) {
|
||||
let res = html;
|
||||
const pattern = /<a\s+href="\..*?\/([^/]+)"/g;
|
||||
res = res.replace(pattern, (match, $1) => match + `onclick="return false;"`); // prevent the default event of <a/>
|
||||
|
||||
// replace img src
|
||||
res = res.replace(/src="(.*?)"/g, (match, src: string) => {
|
||||
if (src.startsWith('http') || src.startsWith('//:')) return match;
|
||||
return `src="${PLUGIN_STATICS_PATH}${packageName}/${src}"`;
|
||||
});
|
||||
return res;
|
||||
}
|
||||
return '';
|
||||
}, [html, packageName]);
|
||||
|
||||
const handleSwitchDocLang = useCallback((e: MouseEvent) => {
|
||||
const url = (e.target as HTMLDivElement).getAttribute('href');
|
||||
if (!url) return;
|
||||
const parsedUrl = new URL(docUrl, window.location.origin);
|
||||
const combinedUrl = new URL(url, parsedUrl);
|
||||
setDocUrl(combinedUrl.pathname);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const md = document.getElementById('pm-md-preview');
|
||||
md.addEventListener('click', handleSwitchDocLang);
|
||||
return () => {
|
||||
removeEventListener('click', handleSwitchDocLang);
|
||||
};
|
||||
}, [handleSwitchDocLang]);
|
||||
|
||||
return (
|
||||
<div className={styles.PluginDocument} id="pm-md-preview">
|
||||
{loading || parseLoading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<div
|
||||
className={`${componentCls} ${hashId} nb-markdown nb-markdown-default nb-markdown-table`}
|
||||
dangerouslySetInnerHTML={{ __html: error ? '' : htmlWithOutRelativeDirect }}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
164
packages/core/client/src/pm/PluginForm/form/PluginNpmForm.tsx
Normal file
164
packages/core/client/src/pm/PluginForm/form/PluginNpmForm.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { ISchema } from '@formily/json-schema';
|
||||
import { useForm } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import { App } from 'antd';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { pick } from 'lodash';
|
||||
import { useAPIClient, useRequest } from '../../../api-client';
|
||||
import { SchemaComponent } from '../../../schema-component';
|
||||
import { IPluginData } from '../../types';
|
||||
|
||||
interface IPluginNpmFormProps {
|
||||
onClose: (refresh?: boolean) => void;
|
||||
isUpgrade: boolean;
|
||||
pluginData?: IPluginData;
|
||||
}
|
||||
|
||||
export const PluginNpmForm: FC<IPluginNpmFormProps> = ({ onClose, isUpgrade, pluginData }) => {
|
||||
const { message } = App.useApp();
|
||||
// const { data, loading } = useRequest<{ data: string[] }>(
|
||||
// {
|
||||
// url: `/pm:npmVersionList/${pluginData?.name}`,
|
||||
// },
|
||||
// {
|
||||
// refreshDeps: [pluginData?.name],
|
||||
// ready: !!pluginData?.name,
|
||||
// },
|
||||
// );
|
||||
|
||||
const versionList = [];
|
||||
// useMemo(() => {
|
||||
// return data?.data.map((item) => ({ label: item, value: item })) || [];
|
||||
// }, [data?.data]);
|
||||
|
||||
const useSaveValues = () => {
|
||||
const api = useAPIClient();
|
||||
const { t } = useTranslation();
|
||||
const form = useForm();
|
||||
|
||||
return {
|
||||
async run() {
|
||||
await form.submit();
|
||||
api.request({
|
||||
url: isUpgrade ? 'pm:update' : 'pm:add',
|
||||
method: 'post',
|
||||
data: {
|
||||
...form.values,
|
||||
},
|
||||
});
|
||||
onClose(true);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useValuesFromProps = (options) => {
|
||||
return useRequest(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
data: isUpgrade ? pick(pluginData, ['name', 'packageName', 'version']) : {},
|
||||
}),
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
const useCancel = () => {
|
||||
return {
|
||||
run() {
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
};
|
||||
const schema = useMemo<ISchema>(() => {
|
||||
const id = uid();
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[id]: {
|
||||
'x-decorator': 'Form',
|
||||
'x-component': 'div',
|
||||
type: 'void',
|
||||
'x-decorator-props': {
|
||||
useValues: '{{ useValuesFromProps }}',
|
||||
},
|
||||
properties: {
|
||||
packageName: {
|
||||
type: 'string',
|
||||
title: "{{t('Npm package name')}}",
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
'x-component-props': {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
registry: {
|
||||
type: 'string',
|
||||
title: "{{t('Registry url')}}",
|
||||
default: 'https://registry.npmjs.org/',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
// required: true,
|
||||
'x-decorator-props': {
|
||||
tooltip: 'Example: https://registry.npmjs.org/',
|
||||
},
|
||||
},
|
||||
authToken: {
|
||||
type: 'string',
|
||||
title: "{{t('Npm authToken')}}",
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
title: "{{t('Version')}}",
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
// enum: '{{versionList}}',
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
layout: 'one-column',
|
||||
style: {
|
||||
justifyContent: 'right',
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
cancel: {
|
||||
title: 'Cancel',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
useAction: '{{ useCancel }}',
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
title: '{{t("Submit")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
useAction: '{{ useSaveValues }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
schema.properties[id].properties.packageName['x-component-props'].disabled = isUpgrade;
|
||||
if (!isUpgrade) {
|
||||
delete schema.properties[id].properties['version'];
|
||||
}
|
||||
return schema;
|
||||
}, [isUpgrade]);
|
||||
// if (loading) {
|
||||
// return <Spin />;
|
||||
// }
|
||||
return <SchemaComponent scope={{ useCancel, useSaveValues, versionList, useValuesFromProps }} schema={schema} />;
|
||||
};
|
||||
PluginNpmForm.displayName = 'PluginNpmForm';
|
123
packages/core/client/src/pm/PluginForm/form/PluginUploadForm.tsx
Normal file
123
packages/core/client/src/pm/PluginForm/form/PluginUploadForm.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { ISchema } from '@formily/json-schema';
|
||||
import { useForm } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import { App } from 'antd';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAPIClient } from '../../../api-client';
|
||||
import { SchemaComponent } from '../../../schema-component';
|
||||
import { IPluginData } from '../../types';
|
||||
|
||||
interface IPluginUploadFormProps {
|
||||
onClose: (refresh?: boolean) => void;
|
||||
isUpgrade: boolean;
|
||||
pluginData?: IPluginData;
|
||||
}
|
||||
|
||||
export const PluginUploadForm: FC<IPluginUploadFormProps> = ({ onClose, pluginData, isUpgrade }) => {
|
||||
const { message } = App.useApp();
|
||||
const useSaveValues = () => {
|
||||
const api = useAPIClient();
|
||||
const { t } = useTranslation();
|
||||
const form = useForm();
|
||||
|
||||
return {
|
||||
async run() {
|
||||
await form.submit();
|
||||
const formData = new FormData();
|
||||
formData.append('file', form.values.uploadFile?.[0]?.originFileObj);
|
||||
if (pluginData?.packageName) {
|
||||
formData.append('packageName', pluginData.packageName);
|
||||
}
|
||||
api.request({
|
||||
url: `pm:${isUpgrade ? 'update' : 'add'}`,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
});
|
||||
onClose(true);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useCancel = () => {
|
||||
return {
|
||||
run() {
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const schema = useMemo<ISchema>(() => {
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[uid()]: {
|
||||
'x-decorator': 'Form',
|
||||
'x-component': 'div',
|
||||
type: 'void',
|
||||
properties: {
|
||||
uploadFile: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Upload.Dragger',
|
||||
required: true,
|
||||
'x-component-props': {
|
||||
action: '',
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
height: '150px',
|
||||
tipContent: `{{t('Drag and drop the file here or click to upload, file size should not exceed 30M')}}`,
|
||||
beforeUpload: (file: RcFile) => {
|
||||
const compressedFileRegex = /\.(zip|rar|tar|gz|bz2|tgz)$/;
|
||||
const isCompressedFile = compressedFileRegex.test(file.name);
|
||||
if (!isCompressedFile) {
|
||||
message.error('File only support zip, rar, tar, gz, bz2!');
|
||||
}
|
||||
|
||||
const fileSizeLimit = file.size / 1024 / 1024 < 30;
|
||||
if (!fileSizeLimit) {
|
||||
message.error('File must smaller than 30MB!');
|
||||
}
|
||||
return false;
|
||||
return isCompressedFile && fileSizeLimit;
|
||||
},
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
layout: 'one-column',
|
||||
style: {
|
||||
justifyContent: 'right',
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
cancel: {
|
||||
title: 'Cancel',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
useAction: '{{ useCancel }}',
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
title: '{{t("Submit")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
useAction: '{{ useSaveValues }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
return <SchemaComponent scope={{ useCancel, useSaveValues }} schema={schema} />;
|
||||
};
|
117
packages/core/client/src/pm/PluginForm/form/PluginUrlForm.tsx
Normal file
117
packages/core/client/src/pm/PluginForm/form/PluginUrlForm.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { ISchema } from '@formily/json-schema';
|
||||
import { useForm } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import { App } from 'antd';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAPIClient, useRequest } from '../../../api-client';
|
||||
import { SchemaComponent } from '../../../schema-component';
|
||||
import { IPluginData } from '../../types';
|
||||
|
||||
interface IPluginUrlFormProps {
|
||||
onClose: (refresh?: boolean) => void;
|
||||
isUpgrade?: boolean;
|
||||
pluginData?: IPluginData;
|
||||
}
|
||||
|
||||
export const PluginUrlForm: FC<IPluginUrlFormProps> = ({ onClose, pluginData, isUpgrade }) => {
|
||||
const { message } = App.useApp();
|
||||
const useSaveValues = () => {
|
||||
const api = useAPIClient();
|
||||
const { t } = useTranslation();
|
||||
const form = useForm();
|
||||
|
||||
return {
|
||||
async run() {
|
||||
const compressedFileUrl = form.values.compressedFileUrl;
|
||||
if (!compressedFileUrl) return;
|
||||
await form.submit();
|
||||
const data = {
|
||||
compressedFileUrl,
|
||||
};
|
||||
if (pluginData?.packageName) {
|
||||
data['packageName'] = pluginData.packageName;
|
||||
}
|
||||
api.request({
|
||||
url: `pm:${isUpgrade ? 'update' : 'add'}`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
onClose(true);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useValuesFromProps = (options) => {
|
||||
return useRequest(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
data: { compressedFileUrl: pluginData.compressedFileUrl },
|
||||
}),
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
const useCancel = () => {
|
||||
return {
|
||||
run() {
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const schema = useMemo<ISchema>(() => {
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[uid()]: {
|
||||
'x-decorator': 'Form',
|
||||
'x-component': 'div',
|
||||
type: 'void',
|
||||
'x-decorator-props': {
|
||||
useValues: '{{ useValuesFromProps }}',
|
||||
},
|
||||
properties: {
|
||||
compressedFileUrl: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
layout: 'one-column',
|
||||
style: {
|
||||
justifyContent: 'right',
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
cancel: {
|
||||
title: 'Cancel',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
useAction: '{{ useCancel }}',
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
title: '{{t("Submit")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
useAction: '{{ useSaveValues }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <SchemaComponent scope={{ useCancel, useValuesFromProps, useSaveValues }} schema={schema} />;
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
import { Modal, Radio } from 'antd';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useStyles } from '../../style';
|
||||
|
||||
import { PluginNpmForm } from '../form/PluginNpmForm';
|
||||
import { PluginUploadForm } from '../form/PluginUploadForm';
|
||||
import { PluginUrlForm } from '../form/PluginUrlForm';
|
||||
|
||||
interface IPluginFormProps {
|
||||
onClose: (refresh?: boolean) => void;
|
||||
isShow: boolean;
|
||||
}
|
||||
|
||||
export const PluginAddModal: FC<IPluginFormProps> = ({ onClose, isShow }) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useStyles();
|
||||
const [type, setType] = useState<'npm' | 'upload' | 'url'>('npm');
|
||||
|
||||
return (
|
||||
<Modal onCancel={() => onClose()} footer={null} destroyOnClose title={t('Add plugin')} width={580} open={isShow}>
|
||||
{/* <label style={{ fontWeight: 'bold' }}>{t('Source')}:</label> */}
|
||||
<div style={{ marginTop: theme.marginLG, marginBottom: theme.marginLG }}>
|
||||
<Radio.Group optionType="button" defaultValue={type} onChange={(e) => setType(e.target.value)}>
|
||||
<Radio value="npm">{t('Npm package')}</Radio>
|
||||
<Radio value="upload">{t('Upload plugin')}</Radio>
|
||||
<Radio value="url">{t('Compressed file url')}</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{type === 'npm' && <PluginNpmForm onClose={onClose} isUpgrade={false} />}
|
||||
{type === 'upload' && <PluginUploadForm onClose={onClose} isUpgrade={false} />}
|
||||
{type === 'url' && <PluginUrlForm onClose={onClose} />}
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
import { Modal, Radio } from 'antd';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useStyles } from '../../style';
|
||||
import { IPluginData } from '../../types';
|
||||
import { PluginNpmForm } from '../form/PluginNpmForm';
|
||||
import { PluginUploadForm } from '../form/PluginUploadForm';
|
||||
import { PluginUrlForm } from '../form/PluginUrlForm';
|
||||
|
||||
interface IPluginUpgradeModalProps {
|
||||
onClose: (refresh?: boolean) => void;
|
||||
isShow: boolean;
|
||||
pluginData: IPluginData;
|
||||
}
|
||||
|
||||
export const PluginUpgradeModal: FC<IPluginUpgradeModalProps> = ({ onClose, isShow, pluginData }) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useStyles();
|
||||
|
||||
const [type, setType] = useState<'npm' | 'upload' | 'url'>('npm');
|
||||
|
||||
return (
|
||||
<Modal onCancel={() => onClose()} footer={null} destroyOnClose title={t('Update plugin')} width={580} open={isShow}>
|
||||
{/* <label style={{ fontWeight: 'bold' }}>{t('Source')}:</label> */}
|
||||
<div style={{ marginTop: theme.marginLG, marginBottom: theme.marginLG }}>
|
||||
<Radio.Group optionType="button" defaultValue={type} onChange={(e) => setType(e.target.value)}>
|
||||
<Radio value="npm">{t('Npm package')}</Radio>
|
||||
<Radio value="upload">{t('Upload plugin')}</Radio>
|
||||
<Radio value="url">{t('Compressed file url')}</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{type === 'npm' && <PluginNpmForm isUpgrade onClose={onClose} pluginData={pluginData} />}
|
||||
{type === 'upload' && <PluginUploadForm isUpgrade onClose={onClose} pluginData={pluginData} />}
|
||||
{type === 'url' && <PluginUrlForm isUpgrade onClose={onClose} pluginData={pluginData} />}
|
||||
</Modal>
|
||||
);
|
||||
};
|
222
packages/core/client/src/pm/PluginManager.tsx
Normal file
222
packages/core/client/src/pm/PluginManager.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
export * from './PluginManagerLink';
|
||||
import { PageHeader } from '@ant-design/pro-layout';
|
||||
import { useDebounce } from 'ahooks';
|
||||
import { Button, Divider, Input, Result, Space, Spin, Tabs } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||
import { useRequest } from '../api-client';
|
||||
import { useToken } from '../style';
|
||||
import { PluginCard } from './PluginCard';
|
||||
import { PluginAddModal } from './PluginForm/modal/PluginAddModal';
|
||||
import { useStyles } from './style';
|
||||
import { IPluginData } from './types';
|
||||
|
||||
export interface TData {
|
||||
data: IPluginData[];
|
||||
meta: IMetaData;
|
||||
}
|
||||
|
||||
export interface IMetaData {
|
||||
count: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPage: number;
|
||||
allowedActions: AllowedActions;
|
||||
}
|
||||
|
||||
export interface AllowedActions {
|
||||
view: number[];
|
||||
update: number[];
|
||||
destroy: number[];
|
||||
}
|
||||
|
||||
const LocalPlugins = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useStyles();
|
||||
const { data, loading, refresh } = useRequest<TData>({
|
||||
url: 'pm:list',
|
||||
});
|
||||
const filterList = useMemo(() => {
|
||||
let list = data?.data || [];
|
||||
list = list.reverse();
|
||||
return [
|
||||
{
|
||||
type: 'All',
|
||||
list: list,
|
||||
},
|
||||
{
|
||||
type: 'Built-in',
|
||||
list: _.filter(list, (item) => item.builtIn),
|
||||
},
|
||||
{
|
||||
type: 'Enabled',
|
||||
list: _.filter(list, (item) => item.enabled),
|
||||
},
|
||||
{
|
||||
type: 'Not enabled',
|
||||
list: _.filter(list, (item) => !item.enabled),
|
||||
},
|
||||
{
|
||||
type: 'Problematic',
|
||||
list: _.filter(list, (item) => !item.isCompatible),
|
||||
},
|
||||
];
|
||||
}, [data?.data]);
|
||||
|
||||
const [filterIndex, setFilterIndex] = useState(0);
|
||||
const [isShowAddForm, setShowAddForm] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const debouncedSearchValue = useDebounce(searchValue, { wait: 100 });
|
||||
|
||||
const pluginList = useMemo(() => {
|
||||
let list = filterList[filterIndex]?.list || [];
|
||||
if (debouncedSearchValue) {
|
||||
list = _.filter(
|
||||
list,
|
||||
(item) =>
|
||||
item.name?.includes(debouncedSearchValue) ||
|
||||
item.description?.includes(debouncedSearchValue) ||
|
||||
item.displayName?.includes(debouncedSearchValue) ||
|
||||
item.packageName?.includes(debouncedSearchValue),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [filterIndex, filterList, debouncedSearchValue]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchValue(value);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginAddModal
|
||||
isShow={isShowAddForm}
|
||||
onClose={(isRefresh) => {
|
||||
setShowAddForm(false);
|
||||
// if (isRefresh) refresh();
|
||||
}}
|
||||
/>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{ marginBottom: theme.marginLG }}
|
||||
className={css`
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
<Space size={theme.marginXXS} split={<Divider type="vertical" />}>
|
||||
{filterList.map((item, index) => (
|
||||
<a
|
||||
onClick={() => setFilterIndex(index)}
|
||||
key={item.type}
|
||||
style={{ fontWeight: filterIndex === index ? 'bold' : 'normal' }}
|
||||
>
|
||||
{t(item.type)}({item.list?.length})
|
||||
</a>
|
||||
))}
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={t('Search plugin')}
|
||||
onChange={(e) => handleSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div>
|
||||
<Space>
|
||||
<Button onClick={() => setShowAddForm(true)} type="primary">
|
||||
{t('Add new')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css`
|
||||
--grid-gutter: ${theme.margin}px;
|
||||
--extensions-card-width: 350px;
|
||||
display: grid;
|
||||
grid-column-gap: var(--grid-gutter);
|
||||
grid-row-gap: var(--grid-gutter);
|
||||
grid-template-columns: repeat(auto-fill, var(--extensions-card-width));
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
`}
|
||||
>
|
||||
{pluginList.map((item) => (
|
||||
<PluginCard key={item.name} data={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MarketplacePlugins = () => {
|
||||
const { token } = useToken();
|
||||
const { t } = useTranslation();
|
||||
return <div style={{ fontSize: token.fontSizeXL, color: token.colorText }}>{t('Coming soon...')}</div>;
|
||||
};
|
||||
|
||||
export const PluginManager = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { tabName = 'local' } = params;
|
||||
const { t } = useTranslation();
|
||||
const { snippets = [] } = useACLRoleContext();
|
||||
const { styles } = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
const { tabName } = params;
|
||||
if (!tabName) {
|
||||
navigate(`/admin/pm/list/local/`, { replace: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return snippets.includes('pm') ? (
|
||||
<div>
|
||||
<PageHeader
|
||||
className={styles.pageHeader}
|
||||
ghost={false}
|
||||
title={t('Plugin manager')}
|
||||
footer={
|
||||
<Tabs
|
||||
activeKey={tabName}
|
||||
onChange={(activeKey) => {
|
||||
navigate(`/admin/pm/list/${activeKey}`);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: 'local',
|
||||
label: t('Local'),
|
||||
},
|
||||
{
|
||||
key: 'marketplace',
|
||||
label: t('Marketplace'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={styles.pageContent} style={{ display: 'flex', flexFlow: 'row wrap' }}>
|
||||
{React.createElement(
|
||||
{
|
||||
local: LocalPlugins,
|
||||
marketplace: MarketplacePlugins,
|
||||
}[tabName],
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
||||
);
|
||||
};
|
205
packages/core/client/src/pm/PluginSetting.tsx
Normal file
205
packages/core/client/src/pm/PluginSetting.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
export * from './PluginManagerLink';
|
||||
import { PageHeader } from '@ant-design/pro-layout';
|
||||
import { css } from '@emotion/css';
|
||||
import { Layout, Menu, Result, Tabs } from 'antd';
|
||||
import _, { sortBy } from 'lodash';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||
import { ACLPane } from '../acl/ACLShortcut';
|
||||
import { CollectionManagerPane } from '../collection-manager';
|
||||
import { Icon } from '../icon';
|
||||
import { useCompile } from '../schema-component';
|
||||
import { BlockTemplatesPane } from '../schema-templates';
|
||||
import { SystemSettingsPane } from '../system-settings';
|
||||
import { useStyles } from './style';
|
||||
|
||||
export const SettingsCenterContext = createContext<any>({});
|
||||
|
||||
export const settings = {
|
||||
acl: {
|
||||
title: '{{t("ACL")}}',
|
||||
icon: 'LockOutlined',
|
||||
tabs: {
|
||||
roles: {
|
||||
isBookmark: true,
|
||||
title: '{{t("Roles & Permissions")}}',
|
||||
component: () => <ACLPane />,
|
||||
},
|
||||
},
|
||||
},
|
||||
'ui-schema-storage': {
|
||||
title: '{{t("Block templates")}}',
|
||||
icon: 'LayoutOutlined',
|
||||
tabs: {
|
||||
'block-templates': {
|
||||
title: '{{t("Block templates")}}',
|
||||
component: BlockTemplatesPane,
|
||||
},
|
||||
},
|
||||
},
|
||||
'collection-manager': {
|
||||
icon: 'DatabaseOutlined',
|
||||
title: '{{t("Collection manager")}}',
|
||||
tabs: {
|
||||
collections: {
|
||||
isBookmark: true,
|
||||
title: '{{t("Collections & Fields")}}',
|
||||
component: CollectionManagerPane,
|
||||
},
|
||||
},
|
||||
},
|
||||
'system-settings': {
|
||||
icon: 'SettingOutlined',
|
||||
title: '{{t("System settings")}}',
|
||||
tabs: {
|
||||
'system-settings': {
|
||||
isBookmark: true,
|
||||
title: '{{t("System settings")}}',
|
||||
component: SystemSettingsPane,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getPluginsTabs = _.memoize((items, snippets) => {
|
||||
const pluginsTabs = Object.keys(items).map((plugin) => {
|
||||
const tabsObj = items[plugin].tabs;
|
||||
const tabs = sortBy(
|
||||
Object.keys(tabsObj).map((tab) => {
|
||||
return {
|
||||
key: tab,
|
||||
...tabsObj[tab],
|
||||
isAllow: snippets.includes('pm.*') && !snippets?.includes(`!pm.${plugin}.${tab}`),
|
||||
};
|
||||
}),
|
||||
(o) => !o.isAllow,
|
||||
);
|
||||
return {
|
||||
...items[plugin],
|
||||
key: plugin,
|
||||
tabs,
|
||||
isAllow: !tabs.every((v) => !v.isAllow),
|
||||
};
|
||||
});
|
||||
return sortBy(pluginsTabs, (o) => !o.isAllow);
|
||||
});
|
||||
|
||||
export const SettingsCenter = () => {
|
||||
const { styles } = useStyles();
|
||||
const { snippets = [] } = useACLRoleContext();
|
||||
const params = useParams<any>();
|
||||
const navigate = useNavigate();
|
||||
const items = useContext(SettingsCenterContext);
|
||||
const pluginsTabs = getPluginsTabs(items, snippets);
|
||||
const compile = useCompile();
|
||||
const firstUri = useMemo(() => {
|
||||
const pluginName = pluginsTabs[0].key;
|
||||
const tabName = pluginsTabs[0].tabs[0].key;
|
||||
return `/admin/settings/${pluginName}/${tabName}`;
|
||||
}, [pluginsTabs]);
|
||||
const { pluginName, tabName } = params;
|
||||
const activePlugin = pluginsTabs.find((v) => v.key === pluginName);
|
||||
const aclPluginTabCheck = activePlugin?.isAllow && activePlugin.tabs.find((v) => v.key === tabName)?.isAllow;
|
||||
if (!pluginName) {
|
||||
return <Navigate replace to={firstUri} />;
|
||||
}
|
||||
if (!items[pluginName]) {
|
||||
return <Navigate replace to={firstUri} />;
|
||||
}
|
||||
if (!tabName) {
|
||||
const firstTabName = Object.keys(items[pluginName]?.tabs).shift();
|
||||
return <Navigate replace to={`/admin/settings/${pluginName}/${firstTabName}`} />;
|
||||
}
|
||||
const component = items[pluginName]?.tabs?.[tabName]?.component;
|
||||
const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
|
||||
const menuItems: any = pluginsTabs
|
||||
.filter((plugin) => plugin.isAllow)
|
||||
.map((plugin) => {
|
||||
return {
|
||||
label: compile(plugin.title),
|
||||
key: plugin.key,
|
||||
icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<Layout>
|
||||
<Layout.Sider
|
||||
className={css`
|
||||
height: 100%;
|
||||
/* position: fixed;
|
||||
padding-top: 46px; */
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
z-index: 100;
|
||||
.ant-layout-sider-children {
|
||||
top: 46px;
|
||||
position: fixed;
|
||||
width: 200px;
|
||||
height: calc(100vh - 46px);
|
||||
}
|
||||
`}
|
||||
theme={'light'}
|
||||
>
|
||||
<Menu
|
||||
selectedKeys={[pluginName]}
|
||||
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
|
||||
onClick={(e) => {
|
||||
const item = items[e.key];
|
||||
const tabKey = Object.keys(item.tabs).shift();
|
||||
navigate(`/admin/settings/${e.key}/${tabKey}`);
|
||||
}}
|
||||
items={menuItems as any}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout.Content>
|
||||
{aclPluginTabCheck && (
|
||||
<PageHeader
|
||||
className={styles.pageHeader}
|
||||
ghost={false}
|
||||
title={compile(items[pluginName]?.title)}
|
||||
footer={
|
||||
<Tabs
|
||||
activeKey={tabName}
|
||||
onChange={(activeKey) => {
|
||||
navigate(`/admin/settings/${pluginName}/${activeKey}`);
|
||||
}}
|
||||
items={plugin.tabs?.map((tab) => {
|
||||
if (!tab.isAllow) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: compile(tab?.title),
|
||||
key: tab.key,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.pageContent}>
|
||||
{aclPluginTabCheck ? (
|
||||
component && React.createElement(component)
|
||||
) : (
|
||||
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
||||
)}
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsCenterProvider = (props) => {
|
||||
const { settings = {} } = props;
|
||||
const items = useContext(SettingsCenterContext);
|
||||
return (
|
||||
<SettingsCenterContext.Provider value={{ ...items, ...settings }}>{props.children}</SettingsCenterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const PMProvider = (props) => {
|
||||
return <SettingsCenterProvider settings={settings}>{props.children}</SettingsCenterProvider>;
|
||||
};
|
@ -1,367 +1,13 @@
|
||||
export * from './PluginManagerLink';
|
||||
import { PageHeader } from '@ant-design/pro-layout';
|
||||
import { css } from '@emotion/css';
|
||||
import { Layout, Menu, Result, Spin, Tabs } from 'antd';
|
||||
import _, { sortBy } from 'lodash';
|
||||
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||
import { ACLPane } from '../acl/ACLShortcut';
|
||||
import { useRequest } from '../api-client';
|
||||
import React from 'react';
|
||||
import { Plugin } from '../application/Plugin';
|
||||
import { CollectionManagerPane } from '../collection-manager';
|
||||
import { Icon } from '../icon';
|
||||
import { useCompile } from '../schema-component';
|
||||
import { BlockTemplatesPane } from '../schema-templates';
|
||||
import { useToken } from '../style';
|
||||
import { SystemSettingsPane } from '../system-settings';
|
||||
import { BuiltInPluginCard, PluginCard } from './Card';
|
||||
import { PluginManagerLink, SettingsCenterDropdown } from './PluginManagerLink';
|
||||
import { useStyles } from './style';
|
||||
import { PMProvider, SettingsCenter } from './PluginSetting';
|
||||
import { PluginManager } from './PluginManager';
|
||||
|
||||
export interface TData {
|
||||
data: IPluginData[];
|
||||
meta: IMetaData;
|
||||
}
|
||||
export * from './PluginManagerLink';
|
||||
export * from './PluginSetting';
|
||||
export * from './PluginManager';
|
||||
|
||||
export interface IPluginData {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
builtIn: boolean;
|
||||
options: Record<string, unknown>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IMetaData {
|
||||
count: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPage: number;
|
||||
allowedActions: AllowedActions;
|
||||
}
|
||||
|
||||
export interface AllowedActions {
|
||||
view: number[];
|
||||
update: number[];
|
||||
destroy: number[];
|
||||
}
|
||||
|
||||
// TODO: refactor card/built-int card
|
||||
|
||||
export const SettingsCenterContext = createContext<any>({});
|
||||
|
||||
const LocalPlugins = () => {
|
||||
// TODO: useRequest types for data ts type
|
||||
const { data, loading } = useRequest<TData>({
|
||||
url: 'applicationPlugins:list',
|
||||
params: {
|
||||
filter: {
|
||||
'builtIn.$isFalsy': true,
|
||||
},
|
||||
sort: 'name',
|
||||
paginate: false,
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{data?.data?.map((item) => {
|
||||
const { id } = item;
|
||||
return <PluginCard data={item} key={id} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BuiltinPlugins = () => {
|
||||
const { data, loading } = useRequest<{
|
||||
data: IPluginData[];
|
||||
meta: IMetaData;
|
||||
}>({
|
||||
url: 'applicationPlugins:list',
|
||||
params: {
|
||||
filter: {
|
||||
'builtIn.$isTruly': true,
|
||||
},
|
||||
sort: 'name',
|
||||
paginate: false,
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{data?.data?.map((item) => {
|
||||
const { id } = item;
|
||||
return <BuiltInPluginCard data={item} key={id} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MarketplacePlugins = () => {
|
||||
const { token } = useToken();
|
||||
const { t } = useTranslation();
|
||||
return <div style={{ fontSize: token.fontSizeXL, color: token.colorText }}>{t('Coming soon...')}</div>;
|
||||
};
|
||||
|
||||
const PluginList = () => {
|
||||
const params = useParams<any>();
|
||||
const navigate = useNavigate();
|
||||
const { tabName = 'local' } = params;
|
||||
const { t } = useTranslation();
|
||||
const { snippets = [] } = useACLRoleContext();
|
||||
const { styles } = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
const { tabName } = params;
|
||||
if (!tabName) {
|
||||
navigate(`/admin/pm/list/local/`, { replace: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return snippets.includes('pm') ? (
|
||||
<div>
|
||||
<PageHeader
|
||||
className={styles.pageHeader}
|
||||
ghost={false}
|
||||
title={t('Plugin manager')}
|
||||
footer={
|
||||
<Tabs
|
||||
activeKey={tabName}
|
||||
onChange={(activeKey) => {
|
||||
navigate(`/admin/pm/list/${activeKey}`);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: 'local',
|
||||
label: t('Local'),
|
||||
},
|
||||
{
|
||||
key: 'built-in',
|
||||
label: t('Built-in'),
|
||||
},
|
||||
{
|
||||
key: 'marketplace',
|
||||
label: t('Marketplace'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={styles.pageContent} style={{ display: 'flex', flexFlow: 'row wrap' }}>
|
||||
{React.createElement(
|
||||
{
|
||||
local: LocalPlugins,
|
||||
'built-in': BuiltinPlugins,
|
||||
marketplace: MarketplacePlugins,
|
||||
}[tabName],
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
||||
);
|
||||
};
|
||||
|
||||
const settings = {
|
||||
acl: {
|
||||
title: '{{t("ACL")}}',
|
||||
icon: 'LockOutlined',
|
||||
tabs: {
|
||||
roles: {
|
||||
isBookmark: true,
|
||||
title: '{{t("Roles & Permissions")}}',
|
||||
component: () => <ACLPane />,
|
||||
},
|
||||
},
|
||||
},
|
||||
'ui-schema-storage': {
|
||||
title: '{{t("Block templates")}}',
|
||||
icon: 'LayoutOutlined',
|
||||
tabs: {
|
||||
'block-templates': {
|
||||
title: '{{t("Block templates")}}',
|
||||
component: BlockTemplatesPane,
|
||||
},
|
||||
},
|
||||
},
|
||||
'collection-manager': {
|
||||
icon: 'DatabaseOutlined',
|
||||
title: '{{t("Collection manager")}}',
|
||||
tabs: {
|
||||
collections: {
|
||||
isBookmark: true,
|
||||
title: '{{t("Collections & Fields")}}',
|
||||
component: CollectionManagerPane,
|
||||
},
|
||||
},
|
||||
},
|
||||
'system-settings': {
|
||||
icon: 'SettingOutlined',
|
||||
title: '{{t("System settings")}}',
|
||||
tabs: {
|
||||
'system-settings': {
|
||||
isBookmark: true,
|
||||
title: '{{t("System settings")}}',
|
||||
component: SystemSettingsPane,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getPluginsTabs = _.memoize((items, snippets) => {
|
||||
const pluginsTabs = Object.keys(items).map((plugin) => {
|
||||
const tabsObj = items[plugin].tabs;
|
||||
const tabs = sortBy(
|
||||
Object.keys(tabsObj).map((tab) => {
|
||||
return {
|
||||
key: tab,
|
||||
...tabsObj[tab],
|
||||
isAllow: snippets.includes('pm.*') && !snippets?.includes(`!pm.${plugin}.${tab}`),
|
||||
};
|
||||
}),
|
||||
(o) => !o.isAllow,
|
||||
);
|
||||
return {
|
||||
...items[plugin],
|
||||
key: plugin,
|
||||
tabs,
|
||||
isAllow: !tabs.every((v) => !v.isAllow),
|
||||
};
|
||||
});
|
||||
return sortBy(pluginsTabs, (o) => !o.isAllow);
|
||||
});
|
||||
|
||||
const SettingsCenter = () => {
|
||||
const { styles } = useStyles();
|
||||
const { snippets = [] } = useACLRoleContext();
|
||||
const params = useParams<any>();
|
||||
const navigate = useNavigate();
|
||||
const items = useContext(SettingsCenterContext);
|
||||
const pluginsTabs = getPluginsTabs(items, snippets);
|
||||
const compile = useCompile();
|
||||
const firstUri = useMemo(() => {
|
||||
const pluginName = pluginsTabs[0].key;
|
||||
const tabName = pluginsTabs[0].tabs[0].key;
|
||||
return `/admin/settings/${pluginName}/${tabName}`;
|
||||
}, [pluginsTabs]);
|
||||
const { pluginName, tabName } = params;
|
||||
const activePlugin = pluginsTabs.find((v) => v.key === pluginName);
|
||||
const aclPluginTabCheck = activePlugin?.isAllow && activePlugin.tabs.find((v) => v.key === tabName)?.isAllow;
|
||||
if (!pluginName) {
|
||||
return <Navigate replace to={firstUri} />;
|
||||
}
|
||||
if (!items[pluginName]) {
|
||||
return <Navigate replace to={firstUri} />;
|
||||
}
|
||||
if (!tabName) {
|
||||
const firstTabName = Object.keys(items[pluginName]?.tabs).shift();
|
||||
return <Navigate replace to={`/admin/settings/${pluginName}/${firstTabName}`} />;
|
||||
}
|
||||
const component = items[pluginName]?.tabs?.[tabName]?.component;
|
||||
const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
|
||||
const menuItems: any = pluginsTabs
|
||||
.filter((plugin) => plugin.isAllow)
|
||||
.map((plugin) => {
|
||||
return {
|
||||
label: compile(plugin.title),
|
||||
key: plugin.key,
|
||||
icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<Layout>
|
||||
<Layout.Sider
|
||||
className={css`
|
||||
height: 100%;
|
||||
/* position: fixed;
|
||||
padding-top: 46px; */
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
z-index: 100;
|
||||
.ant-layout-sider-children {
|
||||
top: 46px;
|
||||
position: fixed;
|
||||
width: 200px;
|
||||
height: calc(100vh - 46px);
|
||||
}
|
||||
`}
|
||||
theme={'light'}
|
||||
>
|
||||
<Menu
|
||||
selectedKeys={[pluginName]}
|
||||
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
|
||||
onClick={(e) => {
|
||||
const item = items[e.key];
|
||||
const tabKey = Object.keys(item.tabs).shift();
|
||||
navigate(`/admin/settings/${e.key}/${tabKey}`);
|
||||
}}
|
||||
items={menuItems as any}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout.Content>
|
||||
{aclPluginTabCheck && (
|
||||
<PageHeader
|
||||
className={styles.pageHeader}
|
||||
ghost={false}
|
||||
title={compile(items[pluginName]?.title)}
|
||||
footer={
|
||||
<Tabs
|
||||
activeKey={tabName}
|
||||
onChange={(activeKey) => {
|
||||
navigate(`/admin/settings/${pluginName}/${activeKey}`);
|
||||
}}
|
||||
items={plugin.tabs?.map((tab) => {
|
||||
if (!tab.isAllow) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: compile(tab?.title),
|
||||
key: tab.key,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.pageContent}>
|
||||
{aclPluginTabCheck ? (
|
||||
component && React.createElement(component)
|
||||
) : (
|
||||
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
||||
)}
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsCenterProvider = (props) => {
|
||||
const { settings = {} } = props;
|
||||
const items = useContext(SettingsCenterContext);
|
||||
return (
|
||||
<SettingsCenterContext.Provider value={{ ...items, ...settings }}>{props.children}</SettingsCenterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const PMProvider = (props) => {
|
||||
return <SettingsCenterProvider settings={settings}>{props.children}</SettingsCenterProvider>;
|
||||
};
|
||||
export class PMPlugin extends Plugin {
|
||||
async load() {
|
||||
this.addComponents();
|
||||
@ -379,15 +25,15 @@ export class PMPlugin extends Plugin {
|
||||
addRoutes() {
|
||||
this.app.router.add('admin.pm.list', {
|
||||
path: '/admin/pm/list',
|
||||
element: <PluginList />,
|
||||
element: <PluginManager />,
|
||||
});
|
||||
this.app.router.add('admin.pm.list-tab', {
|
||||
path: '/admin/pm/list/:tabName',
|
||||
element: <PluginList />,
|
||||
element: <PluginManager />,
|
||||
});
|
||||
this.app.router.add('admin.pm.list-tab-mdfile', {
|
||||
path: '/admin/pm/list/:tabName/:mdfile',
|
||||
element: <PluginList />,
|
||||
element: <PluginManager />,
|
||||
});
|
||||
|
||||
this.app.router.add('admin.settings.list', {
|
||||
|
@ -2,9 +2,13 @@ import { createStyles } from 'antd-style';
|
||||
|
||||
export const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
cardActionDisabled: {
|
||||
color: token.colorTextDisabled,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
pageHeader: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
paddingBottom: 0,
|
||||
backgroundColor: token.colorBgContainer,
|
||||
paddingTop: token.paddingSM,
|
||||
paddingInline: token.paddingLG,
|
||||
'.ant-page-header-footer': { marginBlockStart: '0' },
|
||||
@ -15,22 +19,20 @@ export const useStyles = createStyles(({ token }) => {
|
||||
color: token.colorText,
|
||||
},
|
||||
},
|
||||
|
||||
pageContent: {
|
||||
margin: token.marginLG,
|
||||
},
|
||||
// pageContent: {
|
||||
// marginTop: token.margin,
|
||||
// marginBottom: token.marginLG,
|
||||
// background: 'transparent',
|
||||
// minHeight: '80vh',
|
||||
// },
|
||||
|
||||
PluginDetail: {
|
||||
'.ant-modal-header': { paddingBottom: token.paddingXS },
|
||||
'.ant-modal-body': { paddingTop: 0 },
|
||||
'.ant-modal-content': {
|
||||
'.plugin-desc': { paddingBottom: token.paddingXS },
|
||||
},
|
||||
'.version-tag': {
|
||||
verticalAlign: 'middle',
|
||||
marginTop: -token.marginXXS,
|
||||
marginLeft: token.marginXS,
|
||||
},
|
||||
PluginDetailBaseInfo: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginBottom: token.margin,
|
||||
},
|
||||
|
||||
PluginDocument: {
|
||||
@ -40,13 +42,6 @@ export const useStyles = createStyles(({ token }) => {
|
||||
overflowY: 'auto',
|
||||
},
|
||||
|
||||
CommonCard: {
|
||||
width: `calc(20% - ${token.marginLG}px)`,
|
||||
marginRight: token.marginLG,
|
||||
marginBottom: token.marginLG,
|
||||
transition: 'all 0.35s ease-in-out',
|
||||
},
|
||||
|
||||
avatar: {
|
||||
'.ant-card-meta-avatar': {
|
||||
marginTop: '8px',
|
||||
|
23
packages/core/client/src/pm/types.ts
Normal file
23
packages/core/client/src/pm/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export interface IPluginData {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
name: string;
|
||||
displayName: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
builtIn: boolean;
|
||||
registry?: string;
|
||||
authToken?: string;
|
||||
compressedFileUrl?: string;
|
||||
options: Record<string, unknown>;
|
||||
description?: string;
|
||||
type: 'npm' | 'upload' | 'url';
|
||||
isCompatible?: boolean;
|
||||
readmeUrl: string;
|
||||
changelogUrl: string;
|
||||
error: boolean;
|
||||
updatable?: boolean;
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Layout } from 'antd';
|
||||
import { useSessionStorageState } from 'ahooks';
|
||||
import { App, Layout } from 'antd';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Link, Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ACLRolesCheckProvider,
|
||||
CurrentAppInfoProvider,
|
||||
@ -60,6 +61,8 @@ const useMenuProps = () => {
|
||||
};
|
||||
|
||||
const MenuEditor = (props) => {
|
||||
const { notification } = App.useApp();
|
||||
const [hasNotice, setHasNotice] = useSessionStorageState('plugin-notice', { defaultValue: false });
|
||||
const { setTitle } = useDocumentTitle();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<any>();
|
||||
@ -140,6 +143,45 @@ const MenuEditor = (props) => {
|
||||
}
|
||||
return s;
|
||||
}, [data?.data]);
|
||||
|
||||
useRequest(
|
||||
{
|
||||
url: 'applicationPlugins:list',
|
||||
params: {
|
||||
sort: 'id',
|
||||
paginate: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
setHasNotice(true);
|
||||
const errorPlugins = data.filter((item) => !item.isCompatible);
|
||||
if (errorPlugins.length) {
|
||||
notification.error({
|
||||
message: 'Plugin dependencies check failed',
|
||||
description: (
|
||||
<div>
|
||||
<div>
|
||||
These plugins failed dependency checks. Please go to the{' '}
|
||||
<Link to="/admin/pm/list/local/">plugin management page</Link> for more details.{' '}
|
||||
</div>
|
||||
<ul>
|
||||
{errorPlugins.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.displayName} - {item.packageName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
manual: true,
|
||||
// ready: !hasNotice,
|
||||
},
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return render();
|
||||
}
|
||||
|
2
packages/core/create-nocobase-app/templates/app/storage/plugins/.gitignore
vendored
Normal file
2
packages/core/create-nocobase-app/templates/app/storage/plugins/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
@ -21,7 +21,7 @@
|
||||
"packages/app/*/src"
|
||||
],
|
||||
"@{{{name}}}/plugin-*": [
|
||||
"packages/plugins/*/src"
|
||||
"packages/plugins/plugin-*/src"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -46,7 +46,8 @@
|
||||
"ts-node-dev": "1.1.8",
|
||||
"tsconfig-paths": "^3.12.0",
|
||||
"typescript": "5.1.3",
|
||||
"umi": "^4.0.69"
|
||||
"umi": "^4.0.69",
|
||||
"fast-glob": "^3.3.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -2,7 +2,7 @@ const { existsSync } = require('fs');
|
||||
const { resolve, sep } = require('path');
|
||||
const packageJson = require('./package.json');
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
const glob = require('fast-glob');
|
||||
const path = require('path');
|
||||
|
||||
console.log('VERSION: ', packageJson.version);
|
||||
@ -11,7 +11,8 @@ function getUmiConfig() {
|
||||
const { APP_PORT, API_BASE_URL } = process.env;
|
||||
const API_BASE_PATH = process.env.API_BASE_PATH || '/api/';
|
||||
const PROXY_TARGET_URL = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${APP_PORT}`;
|
||||
const LOCAL_STORAGE_BASE_URL = process.env.LOCAL_STORAGE_BASE_URL || '/storage/uploads/';
|
||||
const LOCAL_STORAGE_BASE_URL = '/storage/uploads/';
|
||||
const STATIC_PATH = '/static/';
|
||||
|
||||
function getLocalStorageProxy() {
|
||||
if (LOCAL_STORAGE_BASE_URL.startsWith('http')) {
|
||||
@ -23,6 +24,10 @@ function getUmiConfig() {
|
||||
target: PROXY_TARGET_URL,
|
||||
changeOrigin: true,
|
||||
},
|
||||
[STATIC_PATH]: {
|
||||
target: PROXY_TARGET_URL,
|
||||
changeOrigin: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -68,9 +73,9 @@ function getPackagePaths() {
|
||||
const pkgs = [];
|
||||
for (const key in paths) {
|
||||
if (Object.hasOwnProperty.call(paths, key)) {
|
||||
const dir = paths[key][0];
|
||||
for (let dir of paths[key]) {
|
||||
if (dir.includes('*')) {
|
||||
const files = glob.sync(dir);
|
||||
const files = glob.sync(dir, { cwd: process.cwd(), onlyDirectories: true });
|
||||
for (const file of files) {
|
||||
const dirname = resolve(process.cwd(), file);
|
||||
if (existsSync(dirname)) {
|
||||
@ -89,6 +94,7 @@ function getPackagePaths() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return pkgs;
|
||||
}
|
||||
|
||||
@ -157,13 +163,13 @@ export default function devDynamicImport(packageName: string): Promise<any> {
|
||||
fs.rmdirSync(this.outputPath, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(this.outputPath);
|
||||
const validPluginPaths = this.pluginsPath.filter((pluginPath) => fs.existsSync(pluginPath));
|
||||
const validPluginPaths = this.pluginsPath.filter((pluginsPath) => fs.existsSync(pluginsPath));
|
||||
if (!validPluginPaths.length || process.env.NODE_ENV === 'production') {
|
||||
fs.writeFileSync(this.indexPath, this.emptyIndexContent);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginInfos = validPluginPaths.map((pluginPath) => this.getContent(pluginPath)).flat();
|
||||
const pluginInfos = validPluginPaths.map((pluginsPath) => this.getContent(pluginsPath)).flat();
|
||||
|
||||
// index.ts
|
||||
fs.writeFileSync(this.indexPath, this.indexContent);
|
||||
@ -181,21 +187,23 @@ export default function devDynamicImport(packageName: string): Promise<any> {
|
||||
});
|
||||
}
|
||||
|
||||
getContent(pluginPath) {
|
||||
const pluginFolders = fs.readdirSync(pluginPath);
|
||||
getContent(pluginsPath) {
|
||||
const pluginFolders = glob
|
||||
.sync(['*/package.json', '*/*/package.json'], { cwd: pluginsPath, onlyFiles: true, absolute: true })
|
||||
.map((item) => path.dirname(item));
|
||||
const pluginInfos = pluginFolders
|
||||
.filter((folder) => {
|
||||
const pluginPackageJsonPath = path.join(pluginPath, folder, 'package.json');
|
||||
const pluginSrcClientPath = path.join(pluginPath, folder, 'src', 'client');
|
||||
const pluginPackageJsonPath = path.join(folder, 'package.json');
|
||||
const pluginSrcClientPath = path.join(folder, 'src', 'client');
|
||||
return fs.existsSync(pluginPackageJsonPath) && fs.existsSync(pluginSrcClientPath);
|
||||
})
|
||||
.map((folder) => {
|
||||
const pluginPackageJsonPath = path.join(pluginPath, folder, 'package.json');
|
||||
const pluginPackageJsonPath = path.join(folder, 'package.json');
|
||||
const pluginPackageJson = require(pluginPackageJsonPath);
|
||||
const pluginSrcClientPath = path
|
||||
.relative(this.packagesPath, path.join(pluginPath, folder, 'src', 'client'))
|
||||
.relative(this.packagesPath, path.join(folder, 'src', 'client'))
|
||||
.replaceAll('\\', '/');
|
||||
const pluginFileName = `${path.basename(pluginPath)}_${folder.replaceAll('-', '_')}`;
|
||||
const pluginFileName = `${path.basename(pluginsPath)}_${path.basename(folder).replaceAll('-', '_')}`;
|
||||
const exportStatement = `export { default } from '${pluginSrcClientPath}';`;
|
||||
return { exportStatement, pluginFileName, packageJsonName: pluginPackageJson.name };
|
||||
});
|
||||
|
@ -37,6 +37,11 @@
|
||||
"multer": "^1.4.2",
|
||||
"nanoid": "3.3.4",
|
||||
"semver": "^7.3.7",
|
||||
"ini": "^4.1.1",
|
||||
"@types/ini": "^1.3.31",
|
||||
"decompress": "4.2.1",
|
||||
"@types/decompress": "4.2.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"serve-handler": "^6.1.5",
|
||||
"ws": "^8.13.0",
|
||||
"xpipe": "^1.0.5"
|
||||
|
@ -1,31 +0,0 @@
|
||||
import Application from '../application';
|
||||
import Plugin from '../plugin';
|
||||
|
||||
class TestPlugin extends Plugin {}
|
||||
|
||||
describe('upgrade test', () => {
|
||||
let app: Application;
|
||||
beforeEach(async () => {
|
||||
app = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
dialectModule: require('sqlite3'),
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
},
|
||||
resourcer: {
|
||||
prefix: '/api',
|
||||
},
|
||||
acl: false,
|
||||
dataWrapping: false,
|
||||
registerActions: false,
|
||||
});
|
||||
|
||||
app.plugin(TestPlugin, { name: 'test-plugin' });
|
||||
});
|
||||
|
||||
it('should call upgrade', async () => {
|
||||
await app.upgrade();
|
||||
console.log('1231');
|
||||
});
|
||||
});
|
@ -263,7 +263,9 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
||||
const { maintainingStatus } = options;
|
||||
if (
|
||||
maintainingStatus &&
|
||||
['install', 'upgrade', 'pm.enable', 'pm.disable'].includes(maintainingStatus.command.name)
|
||||
['install', 'upgrade', 'pm.add', 'pm.update', 'pm.enable', 'pm.disable', 'pm.remove'].includes(
|
||||
maintainingStatus.command.name,
|
||||
)
|
||||
) {
|
||||
this.setAppStatus(app.name, 'running', {
|
||||
refresh: true,
|
||||
|
@ -4,7 +4,7 @@ import { actions as authActions, AuthManager } from '@nocobase/auth';
|
||||
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
||||
import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
||||
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
||||
import { Resourcer, ResourceOptions } from '@nocobase/resourcer';
|
||||
import { ResourceOptions, Resourcer } from '@nocobase/resourcer';
|
||||
import { applyMixins, AsyncEmitter, Toposort, ToposortOptions } from '@nocobase/utils';
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommandOptions, ParseOptions } from 'commander';
|
||||
@ -117,6 +117,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
private _maintaining = false;
|
||||
private _maintainingCommandStatus: MaintainingCommandStatus;
|
||||
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
||||
private _actionCommand: Command;
|
||||
|
||||
constructor(public options: ApplicationOptions) {
|
||||
super();
|
||||
@ -303,8 +304,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
this.log.info(`app.reload()`);
|
||||
const oldDb = this._db;
|
||||
this.init();
|
||||
if (!oldDb.closed()) {
|
||||
await oldDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
this.setMaintainingMessage('init plugins');
|
||||
await this.pm.initPlugins();
|
||||
@ -363,6 +366,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
const command = new Command('nocobase')
|
||||
.usage('[command] [options]')
|
||||
.hook('preAction', async (_, actionCommand) => {
|
||||
this._actionCommand = actionCommand;
|
||||
|
||||
this.activatedCommand = {
|
||||
name: getCommandFullName(actionCommand),
|
||||
};
|
||||
@ -426,7 +431,13 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
const _actionCommand = this._actionCommand;
|
||||
if (_actionCommand) {
|
||||
_actionCommand['_optionValues'] = {};
|
||||
_actionCommand['_optionValueSources'] = {};
|
||||
}
|
||||
this.activatedCommand = null;
|
||||
this._actionCommand = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -454,11 +465,15 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
this.setMaintainingMessage('emit afterStart');
|
||||
await this.emitAsync('afterStart', this, options);
|
||||
await this.emitStartedEvent();
|
||||
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
async emitStartedEvent() {
|
||||
await this.emitAsync('__started', this, {
|
||||
maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus),
|
||||
});
|
||||
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
async isStarted() {
|
||||
@ -477,7 +492,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
if (!this._started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._started = false;
|
||||
await this.emitAsync('beforeStop');
|
||||
await this.reload(options);
|
||||
await this.start(options);
|
||||
this.emit('__restarted', this, options);
|
||||
@ -505,6 +522,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
}
|
||||
|
||||
await this.emitAsync('afterStop', this, options);
|
||||
|
||||
this.stopped = true;
|
||||
this.log.info(`${this.name} is stopped`);
|
||||
this._started = false;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import Application from '../application';
|
||||
import { PluginCommandError } from '../errors/plugin-command-error';
|
||||
|
||||
@ -11,9 +12,35 @@ export default (app: Application) => {
|
||||
});
|
||||
|
||||
pm.command('add')
|
||||
.arguments('plugin')
|
||||
.action(async (plugin) => {
|
||||
await app.pm.add(plugin, {}, true);
|
||||
.argument('<pkg>')
|
||||
.option('--registry [registry]')
|
||||
.option('--auth-token [authToken]')
|
||||
.option('--version [version]')
|
||||
.action(async (name, options, cli) => {
|
||||
console.log('pm.add', name, options);
|
||||
try {
|
||||
await app.pm.addViaCLI(name, _.cloneDeep(options));
|
||||
} catch (error) {
|
||||
throw new PluginCommandError(`Failed to add plugin: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
pm.command('update')
|
||||
.argument('<packageName>')
|
||||
.option('--path [path]')
|
||||
.option('--url [url]')
|
||||
.option('--registry [registry]')
|
||||
.option('--auth-token [authToken]')
|
||||
.option('--version [version]')
|
||||
.action(async (packageName, options) => {
|
||||
try {
|
||||
await app.pm.update({
|
||||
...options,
|
||||
packageName,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PluginCommandError(`Failed to update plugin: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
pm.command('enable')
|
||||
|
@ -1,81 +0,0 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const NODE_MODULES = path.join(cwd, 'node_modules');
|
||||
|
||||
const PREFIX = '/api/plugins/client/';
|
||||
|
||||
const isMatchClientStaticUrl = (url: string) => {
|
||||
return url.startsWith(PREFIX);
|
||||
};
|
||||
|
||||
/**
|
||||
* get package name from url
|
||||
*
|
||||
* @example
|
||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => @nocobase/plugin-acl
|
||||
* /api/plugins/client/my-plugin/README.md => my-plugin
|
||||
*/
|
||||
const getPackageName = (url: string) => {
|
||||
const urlArr = url.split('/');
|
||||
return urlArr[4].startsWith('@') ? `${urlArr[4]}/${urlArr[5]}` : urlArr[4];
|
||||
};
|
||||
|
||||
/**
|
||||
* get plugin client static file real path
|
||||
*
|
||||
* @example
|
||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => /node_modules/@nocobase/plugin-acl/dist/client/index.js
|
||||
* /api/plugins/client/my-plugin/README.md => /node_modules/my-plugin/dist/client/README.md
|
||||
*/
|
||||
const getRealPath = (packageName: string, url: string) => {
|
||||
const ext = path.extname(url);
|
||||
const filePath = url.replace(`${PREFIX}${packageName}/`, '');
|
||||
if (ext.toLowerCase() === '.md') {
|
||||
return path.join(NODE_MODULES, packageName, filePath);
|
||||
} else {
|
||||
return path.join(NODE_MODULES, packageName, 'dist', 'client', filePath);
|
||||
}
|
||||
};
|
||||
|
||||
export async function handlePluginStaticFile(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||
if (isMatchClientStaticUrl(req.url)) {
|
||||
// TODO: check packageName in plugins
|
||||
const packageName = getPackageName(req.url);
|
||||
|
||||
const realPath = getRealPath(packageName, req.url);
|
||||
|
||||
try {
|
||||
// get file stats
|
||||
const stats = await fs.promises.stat(realPath);
|
||||
|
||||
const ifModifiedSince = req.headers['if-modified-since'];
|
||||
|
||||
const lastModified = stats.mtime.toUTCString();
|
||||
|
||||
// check cache headers
|
||||
if (ifModifiedSince === lastModified) {
|
||||
res.statusCode = 304;
|
||||
return true;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(cwd, realPath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Length': stats.size,
|
||||
});
|
||||
|
||||
const readStream = fs.createReadStream(relativePath);
|
||||
readStream.pipe(res);
|
||||
} catch (e) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
import { uid } from '@nocobase/utils';
|
||||
import { createStoragePluginsSymlink } from '@nocobase/utils/plugin-symlink';
|
||||
import { Command } from 'commander';
|
||||
import compression from 'compression';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import http, { IncomingMessage, ServerResponse } from 'http';
|
||||
import { promisify } from 'node:util';
|
||||
import { resolve } from 'path';
|
||||
@ -10,6 +13,7 @@ import { parse } from 'url';
|
||||
import xpipe from 'xpipe';
|
||||
import { AppSupervisor } from '../app-supervisor';
|
||||
import { ApplicationOptions } from '../application';
|
||||
import { PLUGIN_STATICS_PATH, getPackageDirByExposeUrl, getPackageNameByExposeUrl } from '../plugin-manager';
|
||||
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
||||
import { IPCSocketClient } from './ipc-socket-client';
|
||||
import { IPCSocketServer } from './ipc-socket-server';
|
||||
@ -123,18 +127,21 @@ export class Gateway extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/plugins/client/')) {
|
||||
// pathname example: /static/plugins/@nocobase/plugins-acl/README.md
|
||||
// protect server files
|
||||
if (pathname.startsWith(PLUGIN_STATICS_PATH) && !pathname.includes('/server/')) {
|
||||
await compress(req, res);
|
||||
const packageName = getPackageNameByExposeUrl(pathname);
|
||||
// /static/plugins/@nocobase/plugins-acl/README.md => /User/projects/nocobase/plugins/acl
|
||||
const publicDir = getPackageDirByExposeUrl(pathname);
|
||||
// /static/plugins/@nocobase/plugins-acl/README.md => README.md
|
||||
const destination = pathname.replace(PLUGIN_STATICS_PATH, '').replace(packageName, '');
|
||||
return handler(req, res, {
|
||||
public: resolve(process.cwd(), 'node_modules'),
|
||||
public: publicDir,
|
||||
rewrites: [
|
||||
{
|
||||
source: '/api/plugins/client/:plugin/:file',
|
||||
destination: '/:plugin/dist/client/:file',
|
||||
},
|
||||
{
|
||||
source: '/api/plugins/client/@:org/:plugin/:file',
|
||||
destination: '/@:org/:plugin/dist/client/:file',
|
||||
source: pathname,
|
||||
destination,
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -192,9 +199,23 @@ export class Gateway extends EventEmitter {
|
||||
return this.requestHandler.bind(this);
|
||||
}
|
||||
|
||||
async watch() {
|
||||
if (!process.env.IS_DEV_CMD) {
|
||||
return;
|
||||
}
|
||||
const file = resolve(process.cwd(), 'storage/app.watch.ts');
|
||||
if (!fs.existsSync(file)) {
|
||||
await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8');
|
||||
}
|
||||
require(file);
|
||||
}
|
||||
|
||||
async run(options: RunOptions) {
|
||||
const isStart = this.isStart();
|
||||
let ipcClient: IPCSocketClient | false;
|
||||
if (isStart) {
|
||||
await this.watch();
|
||||
|
||||
const startOptions = this.getStartOptions();
|
||||
const port = startOptions.port || process.env.APP_PORT || 13000;
|
||||
const host = startOptions.host || process.env.APP_HOST || '0.0.0.0';
|
||||
@ -204,7 +225,7 @@ export class Gateway extends EventEmitter {
|
||||
host,
|
||||
});
|
||||
} else if (!this.isHelp()) {
|
||||
const ipcClient = await this.tryConnectToIPCServer();
|
||||
ipcClient = await this.tryConnectToIPCServer();
|
||||
|
||||
if (ipcClient) {
|
||||
await ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } });
|
||||
@ -214,6 +235,10 @@ export class Gateway extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
if (isStart || !ipcClient) {
|
||||
await createStoragePluginsSymlink();
|
||||
}
|
||||
|
||||
const mainApp = AppSupervisor.getInstance().bootMainApp(options.mainAppOptions);
|
||||
|
||||
mainApp
|
||||
|
@ -1,9 +1,12 @@
|
||||
import cors from '@koa/cors';
|
||||
import Database from '@nocobase/database';
|
||||
import { Resourcer } from '@nocobase/resourcer';
|
||||
import { uid } from '@nocobase/utils';
|
||||
import { Command } from 'commander';
|
||||
import fs from 'fs';
|
||||
import i18next from 'i18next';
|
||||
import bodyParser from 'koa-bodyparser';
|
||||
import { resolve } from 'path';
|
||||
import Application, { ApplicationOptions } from './application';
|
||||
import { parseVariables } from './middlewares';
|
||||
import { dateTemplate } from './middlewares/data-template';
|
||||
@ -111,3 +114,8 @@ export const getCommandFullName = (command: Command) => {
|
||||
}
|
||||
return names.join('.');
|
||||
};
|
||||
|
||||
export const tsxRerunning = async () => {
|
||||
const file = resolve(process.cwd(), 'storage/app.watch.ts');
|
||||
await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8');
|
||||
};
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { DataTypes } from '@nocobase/database';
|
||||
import { Migration } from '../migration';
|
||||
|
||||
export default class extends Migration {
|
||||
async up() {
|
||||
const collection = this.db.getCollection('applicationPlugins');
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const tableNameWithSchema = collection.getTableNameWithSchema();
|
||||
const field = collection.getField('packageName');
|
||||
if (await field.existsInDb()) {
|
||||
return;
|
||||
}
|
||||
await this.db.sequelize.getQueryInterface().addColumn(tableNameWithSchema, 'packageName', {
|
||||
type: DataTypes.STRING,
|
||||
});
|
||||
await this.db.sequelize.getQueryInterface().addConstraint(tableNameWithSchema, {
|
||||
type: 'unique',
|
||||
fields: ['packageName'],
|
||||
});
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
const PREFIX = '/api/plugins/client/';
|
||||
|
||||
/**
|
||||
* get plugin client static file url
|
||||
*
|
||||
* @example
|
||||
* @nocobase/plugin-acl, index.js => /api/plugins/client/@nocobase/plugin-acl/index.js
|
||||
* my-plugin, README.md => /api/plugins/client/my-plugin/README.md
|
||||
*/
|
||||
export const getPackageClientStaticUrl = (packageName: string, filePath: string) => {
|
||||
return `${PREFIX}${packageName}/${filePath}`;
|
||||
};
|
76
packages/core/server/src/plugin-manager/clientStaticUtils.ts
Normal file
76
packages/core/server/src/plugin-manager/clientStaticUtils.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const PLUGIN_STATICS_PATH = '/static/plugins/';
|
||||
|
||||
/**
|
||||
* get package.json path for specific NPM package
|
||||
*/
|
||||
export function getDepPkgPath(packageName: string, cwd?: string) {
|
||||
try {
|
||||
return require.resolve(`${packageName}/package.json`, { paths: cwd ? [cwd] : undefined });
|
||||
} catch {
|
||||
const mainFile = require.resolve(`${packageName}`, { paths: cwd ? [cwd] : undefined });
|
||||
const packageDir = mainFile.slice(0, mainFile.indexOf(packageName.replace('/', path.sep)) + packageName.length);
|
||||
return path.join(packageDir, 'package.json');
|
||||
}
|
||||
}
|
||||
|
||||
export function getPackageDir(packageName: string) {
|
||||
const packageJsonPath = getDepPkgPath(packageName);
|
||||
return path.dirname(packageJsonPath);
|
||||
}
|
||||
|
||||
export function getPackageFilePath(packageName: string, filePath: string) {
|
||||
const packageDir = getPackageDir(packageName);
|
||||
return path.join(packageDir, filePath);
|
||||
}
|
||||
|
||||
export function getPackageFilePathWithExistCheck(packageName: string, filePath: string) {
|
||||
const absolutePath = getPackageFilePath(packageName, filePath);
|
||||
const exists = fs.existsSync(absolutePath);
|
||||
return {
|
||||
filePath: absolutePath,
|
||||
exists,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExposeUrl(packageName: string, filePath: string) {
|
||||
return `${PLUGIN_STATICS_PATH}${packageName}/${filePath}`;
|
||||
}
|
||||
|
||||
export function getExposeReadmeUrl(packageName: string, lang: string) {
|
||||
let READMEPath = null;
|
||||
if (getPackageFilePathWithExistCheck(packageName, `README.${lang}.md`).exists) {
|
||||
READMEPath = `README.${lang}.md`;
|
||||
} else if (getPackageFilePathWithExistCheck(packageName, 'README.md').exists) {
|
||||
READMEPath = 'README.md';
|
||||
}
|
||||
|
||||
return READMEPath ? getExposeUrl(packageName, READMEPath) : null;
|
||||
}
|
||||
|
||||
export function getExposeChangelogUrl(packageName: string) {
|
||||
const { exists } = getPackageFilePathWithExistCheck(packageName, 'CHANGELOG.md');
|
||||
return exists ? getExposeUrl(packageName, 'CHANGELOG.md') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* get package name by client static url
|
||||
*
|
||||
* @example
|
||||
* getPluginNameByClientStaticUrl('/static/plugins/dayjs/index.js') => 'dayjs'
|
||||
* getPluginNameByClientStaticUrl('/static/plugins/@nocobase/foo/README.md') => '@nocobase/foo'
|
||||
*/
|
||||
export function getPackageNameByExposeUrl(pathname: string) {
|
||||
pathname = pathname.replace(PLUGIN_STATICS_PATH, '');
|
||||
const pathArr = pathname.split('/');
|
||||
if (pathname.startsWith('@')) {
|
||||
return pathArr.slice(0, 2).join('/');
|
||||
}
|
||||
return pathArr[0];
|
||||
}
|
||||
|
||||
export function getPackageDirByExposeUrl(pathname: string) {
|
||||
return getPackageDir(getPackageNameByExposeUrl(pathname));
|
||||
}
|
103
packages/core/server/src/plugin-manager/constants.ts
Normal file
103
packages/core/server/src/plugin-manager/constants.ts
Normal file
@ -0,0 +1,103 @@
|
||||
export const APP_NAME = 'nocobase';
|
||||
export const DEFAULT_PLUGIN_STORAGE_PATH = 'storage/plugins';
|
||||
export const DEFAULT_PLUGIN_PATH = 'packages/plugins/';
|
||||
export const pluginPrefix = (
|
||||
process.env.PLUGIN_PACKAGE_PREFIX || '@nocobase/plugin-,@nocobase/preset-,@nocobase/plugin-pro-'
|
||||
).split(',');
|
||||
export const requireRegex = /require\s*\(['"`](.*?)['"`]\)/g;
|
||||
export const importRegex = /^import(?:['"\s]*([\w*${}\s,]+)from\s*)?['"\s]['"\s](.*[@\w_-]+)['"\s].*/gm;
|
||||
export const EXTERNAL = [
|
||||
// nocobase
|
||||
'@nocobase/acl',
|
||||
'@nocobase/actions',
|
||||
'@nocobase/auth',
|
||||
'@nocobase/cache',
|
||||
'@nocobase/client',
|
||||
'@nocobase/database',
|
||||
'@nocobase/evaluators',
|
||||
'@nocobase/logger',
|
||||
'@nocobase/resourcer',
|
||||
'@nocobase/sdk',
|
||||
'@nocobase/server',
|
||||
'@nocobase/test',
|
||||
'@nocobase/utils',
|
||||
|
||||
// @nocobase/auth
|
||||
'jsonwebtoken',
|
||||
|
||||
// @nocobase/cache
|
||||
'cache-manager',
|
||||
|
||||
// @nocobase/database
|
||||
'sequelize',
|
||||
'umzug',
|
||||
'async-mutex',
|
||||
|
||||
// @nocobase/evaluators
|
||||
'@formulajs/formulajs',
|
||||
'mathjs',
|
||||
|
||||
// @nocobase/logger
|
||||
'winston',
|
||||
'winston-daily-rotate-file',
|
||||
|
||||
// koa
|
||||
'koa',
|
||||
'@koa/cors',
|
||||
'@koa/router',
|
||||
'multer',
|
||||
'@koa/multer',
|
||||
'koa-bodyparser',
|
||||
'koa-static',
|
||||
'koa-send',
|
||||
|
||||
// react
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
|
||||
// react-router
|
||||
'react-router',
|
||||
'react-router-dom',
|
||||
|
||||
// antd
|
||||
'antd',
|
||||
'antd-style',
|
||||
'@ant-design/icons',
|
||||
'@ant-design/cssinjs',
|
||||
|
||||
// i18next
|
||||
'i18next',
|
||||
'react-i18next',
|
||||
|
||||
// dnd-kit 相关
|
||||
'@dnd-kit/accessibility',
|
||||
'@dnd-kit/core',
|
||||
'@dnd-kit/modifiers',
|
||||
'@dnd-kit/sortable',
|
||||
'@dnd-kit/utilities',
|
||||
|
||||
// formily 相关
|
||||
'@formily/antd-v5',
|
||||
'@formily/core',
|
||||
'@formily/react',
|
||||
'@formily/json-schema',
|
||||
'@formily/path',
|
||||
'@formily/validator',
|
||||
'@formily/shared',
|
||||
'@formily/reactive',
|
||||
'@formily/reactive-react',
|
||||
|
||||
// utils
|
||||
'dayjs',
|
||||
'mysql2',
|
||||
'pg',
|
||||
'pg-hstore',
|
||||
'sqlite3',
|
||||
'supertest',
|
||||
'axios',
|
||||
'@emotion/css',
|
||||
'ahooks',
|
||||
'lodash',
|
||||
'china-division',
|
||||
];
|
55
packages/core/server/src/plugin-manager/deps.ts
Normal file
55
packages/core/server/src/plugin-manager/deps.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// @ts-ignore
|
||||
import { version } from '../../package.json';
|
||||
|
||||
const deps: Record<string, string> = {
|
||||
'@nocobase': `${version.split('.').slice(0, 2).join('.')}.x`, // 0.12.x
|
||||
'@formily': '2.x',
|
||||
|
||||
'@formily/antd-v5': '1.x',
|
||||
jsonwebtoken: '8.x',
|
||||
'cache-manager': '4.x',
|
||||
sequelize: '6.x',
|
||||
umzug: '3.x',
|
||||
'async-mutex': '0.3.x',
|
||||
'@formulajs/formulajs': '4.x',
|
||||
mathjs: '10.x',
|
||||
winston: '3.x',
|
||||
'winston-daily-rotate-file': '4.x',
|
||||
koa: '2.x',
|
||||
'@koa/cors': '3.x',
|
||||
'@koa/router': '9.x',
|
||||
multer: '1.x',
|
||||
'@koa/multer': '3.x',
|
||||
'koa-bodyparser': '4.x',
|
||||
'koa-static': '5.x',
|
||||
'koa-send': '5.x',
|
||||
react: '18.x',
|
||||
'react-dom': '18.x',
|
||||
'react-router': '6.x',
|
||||
'react-router-dom': '6.x',
|
||||
antd: '5.x',
|
||||
'antd-style': '3.x',
|
||||
'@ant-design/icons': '5.x',
|
||||
'@ant-design/cssinjs': '1.x',
|
||||
i18next: '22.x',
|
||||
'react-i18next': '11.x',
|
||||
'@dnd-kit/accessibility': '3.x',
|
||||
'@dnd-kit/core': '5.x',
|
||||
'@dnd-kit/modifiers': '6.x',
|
||||
'@dnd-kit/sortable': '6.x',
|
||||
'@dnd-kit/utilities': '3.x',
|
||||
dayjs: '1.x',
|
||||
mysql2: '2.x',
|
||||
pg: '8.x',
|
||||
'pg-hstore': '2.x',
|
||||
sqlite3: '5.x',
|
||||
supertest: '6.x',
|
||||
axios: '0.26.x',
|
||||
'@emotion/css': '11.x',
|
||||
ahooks: '3.x',
|
||||
lodash: '4.x',
|
||||
'china-division': '2.x',
|
||||
cronstrue: '2.x',
|
||||
};
|
||||
|
||||
export default deps;
|
@ -1,2 +1,2 @@
|
||||
export * from './clientStaticMiddleware';
|
||||
export * from './clientStaticUtils';
|
||||
export * from './plugin-manager';
|
||||
|
10
packages/core/server/src/plugin-manager/middleware.ts
Normal file
10
packages/core/server/src/plugin-manager/middleware.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { koaMulter as multer } from '@nocobase/utils';
|
||||
|
||||
export async function uploadMiddleware(ctx: Context, next: Next) {
|
||||
if (ctx.action.resourceName === 'pm' && ['add', 'update'].includes(ctx.action.actionName)) {
|
||||
const upload = multer().single('file');
|
||||
return upload(ctx, next);
|
||||
}
|
||||
return next();
|
||||
}
|
@ -7,6 +7,7 @@ export default defineCollection({
|
||||
repository: 'PluginManagerRepository',
|
||||
fields: [
|
||||
{ type: 'string', name: 'name', unique: true },
|
||||
{ type: 'string', name: 'packageName', unique: true },
|
||||
{ type: 'string', name: 'version' },
|
||||
{ type: 'boolean', name: 'enabled' },
|
||||
{ type: 'boolean', name: 'installed' },
|
||||
|
@ -1,20 +1,86 @@
|
||||
import { uid } from '@nocobase/utils';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Application from '../../application';
|
||||
import { getExposeUrl } from '../clientStaticUtils';
|
||||
import PluginManager from '../plugin-manager';
|
||||
|
||||
export default {
|
||||
name: 'pm',
|
||||
actions: {
|
||||
async add(ctx, next) {
|
||||
const pm = ctx.app.pm;
|
||||
const app = ctx.app as Application;
|
||||
const { values = {} } = ctx.action.params;
|
||||
if (values?.packageName) {
|
||||
const args = [];
|
||||
if (values.registry) {
|
||||
args.push('--registry=' + values.registry);
|
||||
}
|
||||
if (values.version) {
|
||||
args.push('--version=' + values.version);
|
||||
}
|
||||
if (values.authToken) {
|
||||
args.push('--auth-token=' + values.authToken);
|
||||
}
|
||||
app.runAsCLI(['pm', 'add', values.packageName, ...args], { from: 'user' });
|
||||
} else if (ctx.file) {
|
||||
const tmpDir = path.resolve(process.cwd(), 'storage', 'tmp');
|
||||
try {
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||
} catch (error) {
|
||||
// empty
|
||||
}
|
||||
const tempFile = path.join(process.cwd(), 'storage/tmp', uid() + path.extname(ctx.file.originalname));
|
||||
await fs.promises.writeFile(tempFile, ctx.file.buffer, 'binary');
|
||||
app.runAsCLI(['pm', 'add', tempFile], { from: 'user' });
|
||||
} else if (values.compressedFileUrl) {
|
||||
app.runAsCLI(['pm', 'add', values.compressedFileUrl], { from: 'user' });
|
||||
}
|
||||
ctx.body = 'ok';
|
||||
await next();
|
||||
},
|
||||
async update(ctx, next) {
|
||||
const app = ctx.app as Application;
|
||||
const values = ctx.action.params.values || {};
|
||||
const args = [];
|
||||
if (values.registry) {
|
||||
args.push('--registry=' + values.registry);
|
||||
}
|
||||
if (values.version) {
|
||||
args.push('--version=' + values.version);
|
||||
}
|
||||
if (values.authToken) {
|
||||
args.push('--auth-token=' + values.authToken);
|
||||
}
|
||||
if (values.compressedFileUrl) {
|
||||
args.push('--url=' + values.compressedFileUrl);
|
||||
}
|
||||
if (ctx.file) {
|
||||
values.packageName = ctx.request.body.packageName;
|
||||
const tmpDir = path.resolve(process.cwd(), 'storage', 'tmp');
|
||||
try {
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||
} catch (error) {
|
||||
// empty
|
||||
}
|
||||
const tempFile = path.join(process.cwd(), 'storage/tmp', uid() + path.extname(ctx.file.originalname));
|
||||
await fs.promises.writeFile(tempFile, ctx.file.buffer, 'binary');
|
||||
args.push(`--url=${tempFile}`);
|
||||
}
|
||||
app.runAsCLI(['pm', 'update', values.packageName, ...args], { from: 'user' });
|
||||
ctx.body = 'ok';
|
||||
await next();
|
||||
},
|
||||
async npmVersionList(ctx, next) {
|
||||
const { filterByTk } = ctx.action.params;
|
||||
if (!filterByTk) {
|
||||
ctx.throw(400, 'plugin name invalid');
|
||||
}
|
||||
await pm.add(filterByTk);
|
||||
ctx.body = filterByTk;
|
||||
const pm = ctx.app.pm;
|
||||
ctx.body = await pm.getNpmVersionList(filterByTk);
|
||||
await next();
|
||||
},
|
||||
async enable(ctx, next) {
|
||||
const pm = ctx.app.pm;
|
||||
const { filterByTk } = ctx.action.params;
|
||||
const app = ctx.app as Application;
|
||||
if (!filterByTk) {
|
||||
@ -25,7 +91,6 @@ export default {
|
||||
await next();
|
||||
},
|
||||
async disable(ctx, next) {
|
||||
const pm = ctx.app.pm;
|
||||
const { filterByTk } = ctx.action.params;
|
||||
if (!filterByTk) {
|
||||
ctx.throw(400, 'plugin name invalid');
|
||||
@ -35,12 +100,7 @@ export default {
|
||||
ctx.body = filterByTk;
|
||||
await next();
|
||||
},
|
||||
async upgrade(ctx, next) {
|
||||
ctx.body = 'ok';
|
||||
await next();
|
||||
},
|
||||
async remove(ctx, next) {
|
||||
const pm = ctx.app.pm;
|
||||
const { filterByTk } = ctx.action.params;
|
||||
if (!filterByTk) {
|
||||
ctx.throw(400, 'plugin name invalid');
|
||||
@ -50,5 +110,45 @@ export default {
|
||||
ctx.body = filterByTk;
|
||||
await next();
|
||||
},
|
||||
async list(ctx, next) {
|
||||
const locale = ctx.getCurrentLocale();
|
||||
const pm = ctx.app.pm as PluginManager;
|
||||
ctx.body = await pm.list({ locale, isPreset: false });
|
||||
await next();
|
||||
},
|
||||
async listEnabled(ctx, next) {
|
||||
const pm = ctx.db.getRepository('applicationPlugins');
|
||||
const PLUGIN_CLIENT_ENTRY_FILE = 'dist/client/index.js';
|
||||
const items = await pm.find({
|
||||
filter: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
ctx.body = items
|
||||
.map((item) => {
|
||||
try {
|
||||
const packageName = PluginManager.getPackageName(item.name);
|
||||
return {
|
||||
...item.toJSON(),
|
||||
packageName,
|
||||
url: getExposeUrl(packageName, PLUGIN_CLIENT_ENTRY_FILE),
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
await next();
|
||||
},
|
||||
async get(ctx, next) {
|
||||
const locale = ctx.getCurrentLocale();
|
||||
const pm = ctx.app.pm as PluginManager;
|
||||
const { filterByTk } = ctx.action.params;
|
||||
if (!filterByTk) {
|
||||
ctx.throw(400, 'plugin name invalid');
|
||||
}
|
||||
ctx.body = await pm.get(filterByTk).toJSON({ locale });
|
||||
await next();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Repository } from '@nocobase/database';
|
||||
import lodash from 'lodash';
|
||||
import { PluginManager } from './plugin-manager';
|
||||
import { PluginData } from './types';
|
||||
|
||||
export class PluginManagerRepository extends Repository {
|
||||
pm: PluginManager;
|
||||
@ -47,6 +48,15 @@ export class PluginManagerRepository extends Repository {
|
||||
return pluginNames;
|
||||
}
|
||||
|
||||
async upgrade(name: string, data: PluginData) {
|
||||
return this.update({
|
||||
filter: {
|
||||
name,
|
||||
},
|
||||
values: data,
|
||||
});
|
||||
}
|
||||
|
||||
async disable(name: string | string[]) {
|
||||
name = lodash.cloneDeep(name);
|
||||
|
||||
@ -74,6 +84,7 @@ export class PluginManagerRepository extends Repository {
|
||||
sort: 'id',
|
||||
});
|
||||
} catch (error) {
|
||||
await this.database.migrator.up();
|
||||
await this.collection.sync({
|
||||
alter: {
|
||||
drop: false,
|
||||
|
@ -1,15 +1,28 @@
|
||||
import { CleanOptions, Collection, SyncOptions } from '@nocobase/database';
|
||||
import { requireModule } from '@nocobase/utils';
|
||||
import { isURL } from '@nocobase/utils';
|
||||
import { fsExists } from '@nocobase/utils/plugin-symlink';
|
||||
import execa from 'execa';
|
||||
import _ from 'lodash';
|
||||
import net from 'net';
|
||||
import { resolve } from 'path';
|
||||
import { resolve, sep } from 'path';
|
||||
import Application from '../application';
|
||||
import { createAppProxy } from '../helper';
|
||||
import { createAppProxy, tsxRerunning } from '../helper';
|
||||
import { Plugin } from '../plugin';
|
||||
import { uploadMiddleware } from './middleware';
|
||||
import collectionOptions from './options/collection';
|
||||
import resourceOptions from './options/resource';
|
||||
import { PluginManagerRepository } from './plugin-manager-repository';
|
||||
import { PluginData } from './types';
|
||||
import {
|
||||
copyTempPackageToStorageAndLinkToNodeModules,
|
||||
downloadAndUnzipToTempDir,
|
||||
getNpmInfo,
|
||||
getPluginInfoByNpm,
|
||||
removeTmpDir,
|
||||
requireModule,
|
||||
requireNoCache,
|
||||
updatePluginByCompressedFileUrl,
|
||||
} from './utils';
|
||||
|
||||
export interface PluginManagerOptions {
|
||||
app: Application;
|
||||
@ -43,31 +56,16 @@ export class PluginManager {
|
||||
this._repository = this.collection.repository as PluginManagerRepository;
|
||||
this._repository.setPluginManager(this);
|
||||
this.app.resourcer.define(resourceOptions);
|
||||
|
||||
this.app.resourcer.use(async (ctx, next) => {
|
||||
await next();
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
if (resourceName === 'applicationPlugins' && actionName === 'list') {
|
||||
const lng = ctx.getCurrentLocale();
|
||||
if (Array.isArray(ctx.body)) {
|
||||
ctx.body = ctx.body.map((plugin) => {
|
||||
const json = plugin.toJSON();
|
||||
const packageName = PluginManager.getPackageName(json.name);
|
||||
const packageJson = PluginManager.getPackageJson(packageName);
|
||||
return {
|
||||
displayName: packageJson[`displayName.${lng}`] || packageJson.displayName,
|
||||
description: packageJson[`description.${lng}`] || packageJson.description,
|
||||
...json,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.app.acl.allow('pm', 'listEnabled', 'public');
|
||||
this.app.acl.registerSnippet({
|
||||
name: 'pm',
|
||||
actions: ['pm:*', 'applicationPlugins:list'],
|
||||
actions: ['pm:*'],
|
||||
});
|
||||
this.app.db.addMigrations({
|
||||
namespace: 'core/pm',
|
||||
directory: resolve(__dirname, '../migrations'),
|
||||
});
|
||||
this.app.resourcer.use(uploadMiddleware);
|
||||
}
|
||||
|
||||
get repository() {
|
||||
@ -75,7 +73,7 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
static getPackageJson(packageName: string) {
|
||||
return require(`${packageName}/package.json`);
|
||||
return requireNoCache(`${packageName}/package.json`);
|
||||
}
|
||||
|
||||
static getPackageName(name: string) {
|
||||
@ -121,9 +119,19 @@ export class PluginManager {
|
||||
throw new Error(`No available packages found, ${name} plugin does not exist`);
|
||||
}
|
||||
|
||||
static resolvePlugin(pluginName: string | typeof Plugin) {
|
||||
static clearCache(packageName: string) {
|
||||
const packageNamePath = packageName.replace('/', sep);
|
||||
Object.keys(require.cache).forEach((key) => {
|
||||
if (key.includes(packageNamePath)) {
|
||||
delete require.cache[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static resolvePlugin(pluginName: string | typeof Plugin, isUpgrade = false, isPkg = false) {
|
||||
if (typeof pluginName === 'string') {
|
||||
const packageName = this.getPackageName(pluginName);
|
||||
const packageName = isPkg ? pluginName : this.getPackageName(pluginName);
|
||||
this.clearCache(packageName);
|
||||
return requireModule(packageName);
|
||||
} else {
|
||||
return pluginName;
|
||||
@ -170,10 +178,8 @@ export class PluginManager {
|
||||
}
|
||||
}
|
||||
|
||||
async create(name: string | string[]) {
|
||||
async create(pluginName: string) {
|
||||
console.log('creating...');
|
||||
const pluginNames = Array.isArray(name) ? name : [name];
|
||||
const { run } = require('@nocobase/cli/src/util');
|
||||
const createPlugin = async (name) => {
|
||||
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
|
||||
const generator = new PluginGenerator({
|
||||
@ -185,12 +191,21 @@ export class PluginManager {
|
||||
});
|
||||
await generator.run();
|
||||
};
|
||||
await Promise.all(pluginNames.map((pluginName) => createPlugin(pluginName)));
|
||||
await run('yarn', ['install']);
|
||||
await createPlugin(pluginName);
|
||||
await this.repository.create({
|
||||
values: {
|
||||
name: pluginName,
|
||||
packageName: pluginName,
|
||||
version: '0.1.0',
|
||||
},
|
||||
});
|
||||
await tsxRerunning();
|
||||
// await createDevPluginSymLink(pluginName);
|
||||
// await this.add(pluginName, { packageName: pluginName }, true);
|
||||
}
|
||||
|
||||
async add(plugin?: any, options: any = {}, insert = false) {
|
||||
if (this.has(plugin)) {
|
||||
async add(plugin?: any, options: any = {}, insert = false, isUpgrade = false) {
|
||||
if (!isUpgrade && this.has(plugin)) {
|
||||
const name = typeof plugin === 'string' ? plugin : plugin.name;
|
||||
this.app.log.warn(`plugin [${name}] added`);
|
||||
return;
|
||||
@ -198,10 +213,24 @@ export class PluginManager {
|
||||
if (!options.name && typeof plugin === 'string') {
|
||||
options.name = plugin;
|
||||
}
|
||||
try {
|
||||
if (typeof plugin === 'string' && options.name && !options.packageName) {
|
||||
const packageName = PluginManager.getPackageName(options.name);
|
||||
options['packageName'] = packageName;
|
||||
}
|
||||
if (options.packageName) {
|
||||
const packageJson = PluginManager.getPackageJson(options.packageName);
|
||||
options['packageJson'] = packageJson;
|
||||
options['version'] = packageJson.version;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// empty
|
||||
}
|
||||
this.app.log.debug(`adding plugin [${options.name}]...`);
|
||||
let P: any;
|
||||
try {
|
||||
P = PluginManager.resolvePlugin(plugin);
|
||||
P = PluginManager.resolvePlugin(options.packageName || plugin, isUpgrade, !!options.packageName);
|
||||
} catch (error) {
|
||||
this.app.log.warn('plugin not found', error);
|
||||
return;
|
||||
@ -212,12 +241,9 @@ export class PluginManager {
|
||||
this.pluginAliases.set(options.name, instance);
|
||||
}
|
||||
if (insert && options.name) {
|
||||
const packageName = PluginManager.getPackageName(options.name);
|
||||
const packageJson = PluginManager.getPackageJson(packageName);
|
||||
await this.repository.updateOrCreate({
|
||||
values: {
|
||||
...options,
|
||||
version: packageJson.version,
|
||||
},
|
||||
filterKeys: ['name'],
|
||||
});
|
||||
@ -451,7 +477,7 @@ export class PluginManager {
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
if (!plugin) {
|
||||
throw new Error(`${pluginName} plugin does not exist`);
|
||||
continue;
|
||||
}
|
||||
if (plugin.enabled) {
|
||||
throw new Error(`${pluginName} plugin is enabled`);
|
||||
@ -466,13 +492,20 @@ export class PluginManager {
|
||||
const plugins: Plugin[] = [];
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.get(pluginName);
|
||||
if (!plugin) {
|
||||
continue;
|
||||
}
|
||||
plugins.push(plugin);
|
||||
this.del(pluginName);
|
||||
// if (plugin.options.type && plugin.options.packageName) {
|
||||
// await removePluginPackage(plugin.options.packageName);
|
||||
// }
|
||||
}
|
||||
await this.app.reload();
|
||||
for (const plugin of plugins) {
|
||||
await plugin.afterRemove();
|
||||
}
|
||||
await this.app.emitStartedEvent();
|
||||
}
|
||||
|
||||
protected async initPresetPlugins() {
|
||||
@ -481,6 +514,200 @@ export class PluginManager {
|
||||
await this.add(p, { enabled: true, isPreset: true, ...opts });
|
||||
}
|
||||
}
|
||||
|
||||
async loadOne(plugin: Plugin) {
|
||||
this.app.setMaintainingMessage(`loading plugin ${plugin.name}...`);
|
||||
if (plugin.state.loaded || !plugin.enabled) {
|
||||
return;
|
||||
}
|
||||
const name = plugin.getName();
|
||||
await plugin.beforeLoad();
|
||||
|
||||
await this.app.emitAsync('beforeLoadPlugin', plugin, {});
|
||||
this.app.logger.debug(`loading plugin [${name}]...`);
|
||||
await plugin.load();
|
||||
plugin.state.loaded = true;
|
||||
await this.app.emitAsync('afterLoadPlugin', plugin, {});
|
||||
this.app.logger.debug(`after load plugin [${name}]...`);
|
||||
|
||||
this.app.setMaintainingMessage(`loaded plugin ${plugin.name}`);
|
||||
}
|
||||
|
||||
async addViaCLI(urlOrName: string, options?: PluginData) {
|
||||
if (isURL(urlOrName)) {
|
||||
await this.addByCompressedFileUrl({
|
||||
...options,
|
||||
compressedFileUrl: urlOrName,
|
||||
});
|
||||
} else if (await fsExists(urlOrName)) {
|
||||
await this.addByCompressedFileUrl({
|
||||
...(options as any),
|
||||
compressedFileUrl: urlOrName,
|
||||
});
|
||||
} else if (options?.registry) {
|
||||
if (!options.name) {
|
||||
const model = await this.repository.findOne({ filter: { packageName: urlOrName } });
|
||||
if (model) {
|
||||
options['name'] = model?.name;
|
||||
}
|
||||
if (!options.name) {
|
||||
options['name'] = urlOrName.replace('@nocobase/plugin-', '');
|
||||
}
|
||||
}
|
||||
await this.addByNpm({
|
||||
...(options as any),
|
||||
packageName: urlOrName,
|
||||
});
|
||||
} else {
|
||||
const opts = {
|
||||
...options,
|
||||
};
|
||||
const model = await this.repository.findOne({ filter: { packageName: urlOrName } });
|
||||
if (model) {
|
||||
opts['name'] = model.name;
|
||||
}
|
||||
if (!opts['name']) {
|
||||
opts['packageName'] = urlOrName;
|
||||
}
|
||||
await this.add(opts['name'] || urlOrName, opts, true);
|
||||
}
|
||||
await this.app.emitStartedEvent();
|
||||
}
|
||||
|
||||
async addByNpm(options: { packageName: string; name?: string; registry: string; authToken?: string }) {
|
||||
let { name = '', registry, packageName, authToken } = options;
|
||||
name = name.trim();
|
||||
registry = registry.trim();
|
||||
packageName = packageName.trim();
|
||||
authToken = authToken?.trim();
|
||||
const { compressedFileUrl } = await getPluginInfoByNpm({
|
||||
packageName,
|
||||
registry,
|
||||
authToken,
|
||||
});
|
||||
return this.addByCompressedFileUrl({ name, compressedFileUrl, registry, authToken, type: 'npm' });
|
||||
}
|
||||
|
||||
async addByFile(options: { file: string; registry?: string; authToken?: string; type?: string; name?: string }) {
|
||||
const { file, authToken } = options;
|
||||
|
||||
const { packageName, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(file, authToken);
|
||||
|
||||
const name = options.name || packageName;
|
||||
|
||||
if (this.has(name)) {
|
||||
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||
throw new Error(`plugin name [${name}] already exists`);
|
||||
}
|
||||
await copyTempPackageToStorageAndLinkToNodeModules(tempFile, tempPackageContentDir, packageName);
|
||||
return this.add(name, { packageName }, true);
|
||||
}
|
||||
|
||||
async addByCompressedFileUrl(options: {
|
||||
compressedFileUrl: string;
|
||||
registry?: string;
|
||||
authToken?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
const { compressedFileUrl, authToken } = options;
|
||||
|
||||
const { packageName, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(
|
||||
compressedFileUrl,
|
||||
authToken,
|
||||
);
|
||||
|
||||
const name = options.name || packageName;
|
||||
|
||||
if (this.has(name)) {
|
||||
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||
throw new Error(`plugin name [${name}] already exists`);
|
||||
}
|
||||
await copyTempPackageToStorageAndLinkToNodeModules(tempFile, tempPackageContentDir, packageName);
|
||||
return this.add(name, { packageName }, true);
|
||||
}
|
||||
|
||||
async update(options: PluginData) {
|
||||
if (options['url']) {
|
||||
options.compressedFileUrl = options['url'];
|
||||
}
|
||||
if (!options.name) {
|
||||
const model = await this.repository.findOne({ filter: { packageName: options.packageName } });
|
||||
options['name'] = model.name;
|
||||
}
|
||||
if (options.compressedFileUrl) {
|
||||
await this.upgradeByCompressedFileUrl(options);
|
||||
} else {
|
||||
await this.upgradeByNpm(options as any);
|
||||
}
|
||||
await this.app.upgrade();
|
||||
}
|
||||
|
||||
async upgradeByNpm(values: PluginData) {
|
||||
const name = values.name;
|
||||
const plugin = this.get(name);
|
||||
if (!this.has(name)) {
|
||||
throw new Error(`plugin name [${name}] not exists`);
|
||||
}
|
||||
if (!plugin.options.packageName || !values.registry) {
|
||||
throw new Error(`plugin name [${name}] not installed by npm`);
|
||||
}
|
||||
const version = values.version?.trim();
|
||||
const registry = values.registry?.trim() || plugin.options.registry;
|
||||
const authToken = values.authToken?.trim() || plugin.options.authToken;
|
||||
const { compressedFileUrl } = await getPluginInfoByNpm({
|
||||
packageName: plugin.options.packageName,
|
||||
registry: registry,
|
||||
authToken: authToken,
|
||||
version,
|
||||
});
|
||||
return this.upgradeByCompressedFileUrl({ compressedFileUrl, name, version, registry, authToken });
|
||||
}
|
||||
|
||||
async upgradeByCompressedFileUrl(options: PluginData) {
|
||||
const { name, compressedFileUrl, authToken } = options;
|
||||
const data = await this.repository.findOne({ filter: { name } });
|
||||
const { version } = await updatePluginByCompressedFileUrl({
|
||||
compressedFileUrl,
|
||||
packageName: data.packageName,
|
||||
authToken: authToken,
|
||||
});
|
||||
await this.add(name, { version, packageName: data.packageName }, true, true);
|
||||
}
|
||||
|
||||
getNameByPackageName(packageName: string) {
|
||||
const prefixes = PluginManager.getPluginPkgPrefix();
|
||||
const prefix = prefixes.find((prefix) => packageName.startsWith(prefix));
|
||||
if (!prefix) {
|
||||
throw new Error(
|
||||
`package name [${packageName}] invalid, just support ${prefixes.join(
|
||||
', ',
|
||||
)}. You can modify process.env.PLUGIN_PACKAGE_PREFIX add more prefix.`,
|
||||
);
|
||||
}
|
||||
return packageName.replace(prefix, '');
|
||||
}
|
||||
|
||||
async list(options: any = {}) {
|
||||
const { locale = 'en-US', isPreset = false } = options;
|
||||
return Promise.all(
|
||||
[...this.getAliases()]
|
||||
.map((name) => {
|
||||
const plugin = this.get(name);
|
||||
if (!isPreset && plugin.options.isPreset) {
|
||||
return;
|
||||
}
|
||||
return plugin.toJSON({ locale });
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
async getNpmVersionList(name: string) {
|
||||
const plugin = this.get(name);
|
||||
const npmInfo = await getNpmInfo(plugin.options.packageName, plugin.options.registry, plugin.options.authToken);
|
||||
return Object.keys(npmInfo.versions);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginManager;
|
||||
|
15
packages/core/server/src/plugin-manager/types.ts
Normal file
15
packages/core/server/src/plugin-manager/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export interface PluginData {
|
||||
name?: string;
|
||||
packageName?: string;
|
||||
version?: string;
|
||||
preVersion?: string;
|
||||
registry?: string;
|
||||
clientUrl?: string;
|
||||
compressedFileUrl?: string;
|
||||
enabled?: boolean;
|
||||
type?: 'url' | 'npm' | 'upload';
|
||||
authToken?: string;
|
||||
installed?: boolean;
|
||||
builtIn?: boolean;
|
||||
options?: any;
|
||||
}
|
552
packages/core/server/src/plugin-manager/utils.ts
Normal file
552
packages/core/server/src/plugin-manager/utils.ts
Normal file
@ -0,0 +1,552 @@
|
||||
import { isURL } from '@nocobase/utils';
|
||||
import { createStoragePluginSymLink } from '@nocobase/utils/plugin-symlink';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import decompress from 'decompress';
|
||||
import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import ini from 'ini';
|
||||
import { builtinModules } from 'module';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import {
|
||||
APP_NAME,
|
||||
DEFAULT_PLUGIN_PATH,
|
||||
DEFAULT_PLUGIN_STORAGE_PATH,
|
||||
EXTERNAL,
|
||||
importRegex,
|
||||
pluginPrefix,
|
||||
requireRegex,
|
||||
} from './constants';
|
||||
import deps from './deps';
|
||||
import { PluginData } from './types';
|
||||
import { getDepPkgPath, getPackageDir, getPackageFilePathWithExistCheck } from './clientStaticUtils';
|
||||
|
||||
/**
|
||||
* get temp dir
|
||||
*
|
||||
* @example
|
||||
* getTempDir() => '/tmp/nocobase'
|
||||
*/
|
||||
export async function getTempDir() {
|
||||
const temporaryDirectory = await fs.realpath(os.tmpdir());
|
||||
return path.join(temporaryDirectory, APP_NAME);
|
||||
}
|
||||
|
||||
export function getPluginStoragePath() {
|
||||
const pluginStoragePath = process.env.PLUGIN_STORAGE_PATH || DEFAULT_PLUGIN_STORAGE_PATH;
|
||||
return path.isAbsolute(pluginStoragePath) ? pluginStoragePath : path.join(process.cwd(), pluginStoragePath);
|
||||
}
|
||||
|
||||
export function getLocalPluginPackagesPathArr(): string[] {
|
||||
const pluginPackagesPathArr = process.env.PLUGIN_PATH || DEFAULT_PLUGIN_PATH;
|
||||
return pluginPackagesPathArr.split(',').map((pluginPackagesPath) => {
|
||||
pluginPackagesPath = pluginPackagesPath.trim();
|
||||
return path.isAbsolute(pluginPackagesPath) ? pluginPackagesPath : path.join(process.cwd(), pluginPackagesPath);
|
||||
});
|
||||
}
|
||||
|
||||
export function getStoragePluginDir(packageName: string) {
|
||||
const pluginStoragePath = getPluginStoragePath();
|
||||
return path.join(pluginStoragePath, packageName);
|
||||
}
|
||||
|
||||
export function getLocalPluginDir(packageDirBasename: string) {
|
||||
const localPluginDir = getLocalPluginPackagesPathArr()
|
||||
.map((pluginPackagesPath) => path.join(pluginPackagesPath, packageDirBasename))
|
||||
.find((pluginDir) => fs.existsSync(pluginDir));
|
||||
|
||||
if (!localPluginDir) {
|
||||
throw new Error(`local plugin "${packageDirBasename}" not found`);
|
||||
}
|
||||
|
||||
return localPluginDir;
|
||||
}
|
||||
|
||||
export function getNodeModulesPluginDir(packageName: string) {
|
||||
return path.join(process.env.NODE_MODULES_PATH, packageName);
|
||||
}
|
||||
|
||||
export function getAuthorizationHeaders(registry?: string, authToken?: string) {
|
||||
const headers = {};
|
||||
if (registry && !authToken) {
|
||||
const npmrcPath = path.join(process.cwd(), '.npmrc');
|
||||
const url = new URL(registry);
|
||||
let envConfig: Record<string, string> = process.env;
|
||||
if (fs.existsSync(npmrcPath)) {
|
||||
const content = fs.readFileSync(path.join(process.cwd(), '.npmrc'), 'utf-8');
|
||||
envConfig = {
|
||||
...envConfig,
|
||||
...ini.parse(content),
|
||||
};
|
||||
}
|
||||
const key = Object.keys(envConfig).find((key) => key.includes(url.host) && key.includes('_authToken'));
|
||||
if (key) {
|
||||
authToken = envConfig[key];
|
||||
}
|
||||
}
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* get latest version from npm
|
||||
*
|
||||
* @example
|
||||
* getLatestVersion('dayjs', 'https://registry.npmjs.org') => '1.10.6'
|
||||
*/
|
||||
export async function getLatestVersion(packageName: string, registry: string, token?: string) {
|
||||
const npmInfo = await getNpmInfo(packageName, registry, token);
|
||||
const latestVersion = npmInfo['dist-tags'].latest;
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
export async function getNpmInfo(packageName: string, registry: string, token?: string) {
|
||||
registry.endsWith('/') && (registry = registry.slice(0, -1));
|
||||
const response = await axios.get(`${registry}/${packageName}`, {
|
||||
headers: getAuthorizationHeaders(registry, token),
|
||||
});
|
||||
try {
|
||||
const data = response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(`${registry} is not a valid registry, '${registry}/${packageName}' response is not a valid json.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function download(url: string, destination: string, options: AxiosRequestConfig = {}) {
|
||||
const response = await axios.get(url, {
|
||||
...options,
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
fs.mkdirpSync(path.dirname(destination));
|
||||
const writer = fs.createWriteStream(destination);
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve);
|
||||
writer.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTmpDir(tempFile: string, tempPackageContentDir: string) {
|
||||
await fs.remove(tempFile);
|
||||
await fs.remove(tempPackageContentDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* download and unzip to node_modules
|
||||
*/
|
||||
export async function downloadAndUnzipToTempDir(fileUrl: string, authToken?: string) {
|
||||
const fileName = path.basename(fileUrl);
|
||||
const tempDir = await getTempDir();
|
||||
const tempFile = path.join(tempDir, fileName);
|
||||
const tempPackageDir = tempFile.replace(path.extname(fileName), '');
|
||||
|
||||
// download and unzip to temp dir
|
||||
await fs.remove(tempPackageDir);
|
||||
await fs.remove(tempFile);
|
||||
|
||||
if (isURL(fileUrl)) {
|
||||
await download(fileUrl, tempFile, {
|
||||
headers: getAuthorizationHeaders(fileUrl, authToken),
|
||||
});
|
||||
} else if (await fs.exists(fileUrl)) {
|
||||
await fs.copy(fileUrl, tempFile);
|
||||
} else {
|
||||
throw new Error(`${fileUrl} does not exist`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(tempFile)) {
|
||||
throw new Error(`download ${fileUrl} failed`);
|
||||
}
|
||||
|
||||
await decompress(tempFile, tempPackageDir);
|
||||
|
||||
if (!fs.existsSync(tempPackageDir)) {
|
||||
await fs.remove(tempFile);
|
||||
throw new Error(`File is not a valid compressed file. Maybe the file need authorization.`);
|
||||
}
|
||||
|
||||
let tempPackageContentDir = tempPackageDir;
|
||||
const files = fs
|
||||
.readdirSync(tempPackageDir, { recursive: false, withFileTypes: true })
|
||||
.filter((item) => item.name !== '__MACOSX');
|
||||
if (
|
||||
files.length === 1 &&
|
||||
files[0].isDirectory() &&
|
||||
fs.existsSync(path.join(tempPackageDir, files[0]['name'], 'package.json'))
|
||||
) {
|
||||
tempPackageContentDir = path.join(tempPackageDir, files[0]['name']);
|
||||
}
|
||||
const packageJsonPath = path.join(tempPackageContentDir, 'package.json');
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||
throw new Error(`decompress ${fileUrl} failed`);
|
||||
}
|
||||
|
||||
const packageJson = requireNoCache(packageJsonPath);
|
||||
const mainFile = path.join(tempPackageContentDir, packageJson.main);
|
||||
if (!fs.existsSync(mainFile)) {
|
||||
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||
throw new Error(`main file ${packageJson.main} not found, Please check if the plugin has been built.`);
|
||||
}
|
||||
|
||||
return {
|
||||
packageName: packageJson.name,
|
||||
version: packageJson.version,
|
||||
tempPackageContentDir,
|
||||
tempFile,
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyTempPackageToStorageAndLinkToNodeModules(
|
||||
tempFile: string,
|
||||
tempPackageContentDir: string,
|
||||
packageName: string,
|
||||
) {
|
||||
const packageDir = getStoragePluginDir(packageName);
|
||||
|
||||
// move to plugin storage dir
|
||||
await fs.remove(packageDir);
|
||||
await fs.move(tempPackageContentDir, packageDir, { overwrite: true });
|
||||
|
||||
// symlink to node_modules
|
||||
await createStoragePluginSymLink(packageName);
|
||||
|
||||
// remove temp dir
|
||||
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||
|
||||
return {
|
||||
packageDir,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* get package info from npm
|
||||
*
|
||||
* @example
|
||||
* getPluginInfoByNpm('dayjs', 'https://registry.npmjs.org')
|
||||
* => { fileUrl: 'https://registry.npmjs.org/dayjs/-/dayjs-1.10.6.tgz', latestVersion: '1.10.6' }
|
||||
*
|
||||
* getPluginInfoByNpm('dayjs', 'https://registry.npmjs.org', '1.1.0')
|
||||
* => { fileUrl: 'https://registry.npmjs.org/dayjs/-/dayjs-1.1.0.tgz', latestVersion: '1.1.0' }
|
||||
*/
|
||||
|
||||
interface GetPluginInfoOptions {
|
||||
packageName: string;
|
||||
registry: string;
|
||||
version?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export async function getPluginInfoByNpm(options: GetPluginInfoOptions) {
|
||||
let { registry, version } = options;
|
||||
const { packageName, authToken } = options;
|
||||
if (registry.endsWith('/')) {
|
||||
registry = registry.slice(0, -1);
|
||||
}
|
||||
if (!version) {
|
||||
version = await getLatestVersion(packageName, registry, authToken);
|
||||
}
|
||||
|
||||
const compressedFileUrl = `${registry}/${packageName}/-/${packageName.split('/').pop()}-${version}.tgz`;
|
||||
|
||||
return { compressedFileUrl, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* scan `src/server` directory to get server packages
|
||||
*
|
||||
* @example
|
||||
* getServerPackages('src/server') => ['dayjs', '@nocobase/plugin-bbb']
|
||||
*/
|
||||
export function getServerPackages(packageDir: string) {
|
||||
function isBuiltinModule(packageName: string) {
|
||||
return builtinModules.includes(packageName);
|
||||
}
|
||||
|
||||
function getSrcPlugins(sourceDir: string): string[] {
|
||||
const importedPlugins = new Set<string>();
|
||||
const exts = ['.js', '.ts', '.jsx', '.tsx'];
|
||||
const importRegex = /import\s+.*?\s+from\s+['"]([^'"\s.].+?)['"];?/g;
|
||||
const requireRegex = /require\s*\(\s*[`'"]([^`'"\s.].+?)[`'"]\s*\)/g;
|
||||
function setPluginsFromContent(reg: RegExp, content: string) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = reg.exec(content))) {
|
||||
let importedPlugin = match[1];
|
||||
if (importedPlugin.startsWith('@')) {
|
||||
// @aa/bb/ccFile => @aa/bb
|
||||
importedPlugin = importedPlugin.split('/').slice(0, 2).join('/');
|
||||
} else {
|
||||
// aa/bbFile => aa
|
||||
importedPlugin = importedPlugin.split('/')[0];
|
||||
}
|
||||
|
||||
if (!isBuiltinModule(importedPlugin)) {
|
||||
importedPlugins.add(importedPlugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function traverseDirectory(directory: string) {
|
||||
const files = fs.readdirSync(directory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(directory, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// recursive
|
||||
traverseDirectory(filePath);
|
||||
} else if (stat.isFile() && !filePath.includes('__tests__')) {
|
||||
if (exts.includes(path.extname(filePath).toLowerCase())) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
setPluginsFromContent(importRegex, content);
|
||||
setPluginsFromContent(requireRegex, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverseDirectory(sourceDir);
|
||||
|
||||
return [...importedPlugins];
|
||||
}
|
||||
|
||||
const srcServerPlugins = getSrcPlugins(path.join(packageDir, 'src/server'));
|
||||
return srcServerPlugins;
|
||||
}
|
||||
|
||||
export function removePluginPackage(packageName: string) {
|
||||
const packageDir = getStoragePluginDir(packageName);
|
||||
const nodeModulesPluginDir = getNodeModulesPluginDir(packageName);
|
||||
return Promise.all([fs.remove(packageDir), fs.remove(nodeModulesPluginDir)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* get package.json
|
||||
*
|
||||
* @example
|
||||
* getPackageJson('dayjs') => { name: 'dayjs', version: '1.0.0', ... }
|
||||
*/
|
||||
export function getPackageJson(pluginName: string) {
|
||||
const packageDir = getStoragePluginDir(pluginName);
|
||||
return getPackageJsonByLocalPath(packageDir);
|
||||
}
|
||||
|
||||
export function getPackageJsonByLocalPath(localPath: string) {
|
||||
if (!fs.existsSync(localPath)) {
|
||||
return null;
|
||||
} else {
|
||||
return requireNoCache(path.join(localPath, 'package.json'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePluginByCompressedFileUrl(
|
||||
options: Partial<Pick<PluginData, 'compressedFileUrl' | 'packageName' | 'authToken'>>,
|
||||
) {
|
||||
const { packageName, version, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(
|
||||
options.compressedFileUrl,
|
||||
options.authToken,
|
||||
);
|
||||
|
||||
if (options.packageName && options.packageName !== packageName) {
|
||||
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||
throw new Error(`Plugin name in package.json must be ${options.packageName}, but got ${packageName}`);
|
||||
}
|
||||
|
||||
const { packageDir } = await copyTempPackageToStorageAndLinkToNodeModules(
|
||||
tempFile,
|
||||
tempPackageContentDir,
|
||||
packageName,
|
||||
);
|
||||
|
||||
return {
|
||||
packageName,
|
||||
packageDir,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNewVersion(plugin: PluginData): Promise<string | false> {
|
||||
if (!(plugin.packageName && plugin.registry)) return false;
|
||||
|
||||
// 1. Check plugin version by npm registry
|
||||
const { version } = await getPluginInfoByNpm({
|
||||
packageName: plugin.packageName,
|
||||
registry: plugin.registry,
|
||||
authToken: plugin.authToken,
|
||||
});
|
||||
// 2. has new version, return true
|
||||
return version !== plugin.version ? version : false;
|
||||
}
|
||||
|
||||
export function removeRequireCache(fileOrPackageName: string) {
|
||||
delete require.cache[require.resolve(fileOrPackageName)];
|
||||
delete require.cache[fileOrPackageName];
|
||||
}
|
||||
|
||||
export function requireNoCache(fileOrPackageName: string) {
|
||||
removeRequireCache(fileOrPackageName);
|
||||
return require(fileOrPackageName);
|
||||
}
|
||||
|
||||
export function requireModule(m: any) {
|
||||
if (typeof m === 'string') {
|
||||
m = require(m);
|
||||
}
|
||||
if (typeof m !== 'object') {
|
||||
return m;
|
||||
}
|
||||
return m.__esModule ? m.default : m;
|
||||
}
|
||||
|
||||
function getExternalVersionFromDistFile(packageName: string): false | Record<string, string> {
|
||||
const { exists, filePath } = getPackageFilePathWithExistCheck(packageName, 'dist/externalVersion.js');
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return requireNoCache(filePath);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function isNotBuiltinModule(packageName: string) {
|
||||
return !builtinModules.includes(packageName);
|
||||
}
|
||||
|
||||
export const isValidPackageName = (str: string) => {
|
||||
const pattern = /^(?:@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_-]+$/;
|
||||
return pattern.test(str);
|
||||
};
|
||||
|
||||
export function getPackageNameFromString(str: string) {
|
||||
// ./xx or ../xx
|
||||
if (str.startsWith('.')) return null;
|
||||
|
||||
const arr = str.split('/');
|
||||
let packageName: string;
|
||||
if (arr[0].startsWith('@')) {
|
||||
// @aa/bb/ccFile => @aa/bb
|
||||
packageName = arr.slice(0, 2).join('/');
|
||||
} else {
|
||||
// aa/bbFile => aa
|
||||
packageName = arr[0];
|
||||
}
|
||||
|
||||
packageName = packageName.trim();
|
||||
|
||||
return isValidPackageName(packageName) ? packageName : null;
|
||||
}
|
||||
|
||||
export function getPackagesFromFiles(files: string[]): string[] {
|
||||
const packageNames = files
|
||||
.map((item) => [
|
||||
...[...item.matchAll(importRegex)].map((item) => item[2]),
|
||||
...[...item.matchAll(requireRegex)].map((item) => item[1]),
|
||||
])
|
||||
.flat()
|
||||
.map(getPackageNameFromString)
|
||||
.filter(Boolean)
|
||||
.filter(isNotBuiltinModule);
|
||||
|
||||
return [...new Set(packageNames)];
|
||||
}
|
||||
|
||||
export function getIncludePackages(sourcePackages: string[], external: string[], pluginPrefix: string[]): string[] {
|
||||
return sourcePackages
|
||||
.filter((packageName) => !external.includes(packageName)) // exclude external
|
||||
.filter((packageName) => !pluginPrefix.some((prefix) => packageName.startsWith(prefix))); // exclude other plugin
|
||||
}
|
||||
|
||||
export function getExcludePackages(sourcePackages: string[], external: string[], pluginPrefix: string[]): string[] {
|
||||
const includePackages = getIncludePackages(sourcePackages, external, pluginPrefix);
|
||||
return sourcePackages.filter((packageName) => !includePackages.includes(packageName));
|
||||
}
|
||||
|
||||
export async function getExternalVersionFromSource(packageName: string) {
|
||||
const packageDir = getPackageDir(packageName);
|
||||
const sourceGlobalFiles: string[] = ['src/**/*.{ts,js,tsx,jsx}', '!src/**/__tests__'];
|
||||
const sourceFilePaths = await fg.glob(sourceGlobalFiles, { cwd: packageDir, absolute: true });
|
||||
const sourceFiles = await Promise.all(sourceFilePaths.map((item) => fs.readFile(item, 'utf-8')));
|
||||
const sourcePackages = getPackagesFromFiles(sourceFiles);
|
||||
const excludePackages = getExcludePackages(sourcePackages, EXTERNAL, pluginPrefix);
|
||||
const data = excludePackages.reduce<Record<string, string>>((prev, packageName) => {
|
||||
const depPkgPath = getDepPkgPath(packageName, packageDir);
|
||||
const depPkg = require(depPkgPath);
|
||||
prev[packageName] = depPkg.version;
|
||||
return prev;
|
||||
}, {});
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface DepCompatible {
|
||||
name: string;
|
||||
result: boolean;
|
||||
versionRange: string;
|
||||
packageVersion: string;
|
||||
}
|
||||
export async function getCompatible(packageName: string) {
|
||||
let externalVersion: Record<string, string>;
|
||||
if (!process.env.IS_DEV_CMD) {
|
||||
const res = getExternalVersionFromDistFile(packageName);
|
||||
if (!res) {
|
||||
return false;
|
||||
} else {
|
||||
externalVersion = res;
|
||||
}
|
||||
} else {
|
||||
externalVersion = await getExternalVersionFromSource(packageName);
|
||||
}
|
||||
|
||||
return Object.keys(externalVersion).reduce<DepCompatible[]>((result, packageName) => {
|
||||
const packageVersion = externalVersion[packageName];
|
||||
const globalPackageName = deps[packageName]
|
||||
? packageName
|
||||
: deps[packageName.split('/')[0]] // @nocobase and @formily
|
||||
? packageName.split('/')[0]
|
||||
: undefined;
|
||||
|
||||
if (globalPackageName) {
|
||||
const versionRange = deps[globalPackageName];
|
||||
result.push({
|
||||
name: packageName,
|
||||
result: semver.satisfies(packageVersion, versionRange, { includePrerelease: true }),
|
||||
versionRange,
|
||||
packageVersion,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function checkCompatible(packageName: string) {
|
||||
const compatible = await getCompatible(packageName);
|
||||
if (!compatible) return false;
|
||||
return compatible.every((item) => item.result);
|
||||
}
|
||||
|
||||
export async function checkAndGetCompatible(packageName: string) {
|
||||
const compatible = await getCompatible(packageName);
|
||||
if (!compatible) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
depsCompatible: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
isCompatible: compatible.every((item) => item.result),
|
||||
depsCompatible: compatible,
|
||||
};
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
import { Model } from '@nocobase/database';
|
||||
import fs from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { Application } from './application';
|
||||
import { InstallOptions } from './plugin-manager';
|
||||
import { InstallOptions, getExposeChangelogUrl, getExposeReadmeUrl } from './plugin-manager';
|
||||
import { checkAndGetCompatible } from './plugin-manager/utils';
|
||||
|
||||
export interface PluginInterface {
|
||||
beforeLoad?: () => void;
|
||||
@ -104,6 +107,30 @@ export abstract class Plugin<O = any> implements PluginInterface {
|
||||
requiredPlugins() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async toJSON(options: any = {}) {
|
||||
const { locale = 'en-US' } = options;
|
||||
const { packageName, packageJson } = this.options;
|
||||
if (!packageName) {
|
||||
return {
|
||||
...this.options,
|
||||
};
|
||||
}
|
||||
const file = await fs.promises.realpath(resolve(process.env.NODE_MODULES_PATH, packageName));
|
||||
const lastUpdated = (await fs.promises.stat(file)).ctime;
|
||||
const others = await checkAndGetCompatible(packageName);
|
||||
return {
|
||||
...this.options,
|
||||
...others,
|
||||
readmeUrl: getExposeReadmeUrl(packageName, locale),
|
||||
changelogUrl: getExposeChangelogUrl(packageName),
|
||||
lastUpdated,
|
||||
file,
|
||||
updatable: file.startsWith(process.env.PLUGIN_STORAGE_PATH),
|
||||
displayName: packageJson[`displayName.${locale}`] || packageJson.displayName,
|
||||
description: packageJson[`description.${locale}`] || packageJson.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default Plugin;
|
||||
|
5
packages/core/utils/plugin-symlink.d.ts
vendored
Normal file
5
packages/core/utils/plugin-symlink.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
export declare function getStoragePluginNames(target: any): Promise<any[]>;
|
||||
export declare function fsExists(path: any): Promise<boolean>;
|
||||
export declare function createStoragePluginSymLink(pluginName: any): Promise<void>;
|
||||
export declare function createStoragePluginsSymlink(): Promise<void>;
|
||||
export declare function createDevPluginSymLink(pluginName: any): Promise<void>;
|
87
packages/core/utils/plugin-symlink.js
Normal file
87
packages/core/utils/plugin-symlink.js
Normal file
@ -0,0 +1,87 @@
|
||||
const { dirname, resolve } = require('path');
|
||||
const { readFile, writeFile, readdir, symlink, unlink, mkdir, stat } = require('fs').promises;
|
||||
|
||||
async function getStoragePluginNames(target) {
|
||||
const plugins = [];
|
||||
const items = await readdir(target);
|
||||
for (const item of items) {
|
||||
if (item.startsWith('@')) {
|
||||
const children = await getStoragePluginNames(resolve(target, item));
|
||||
plugins.push(
|
||||
...children.map((child) => {
|
||||
return `${item}/${child}`;
|
||||
}),
|
||||
);
|
||||
} else if (await fsExists(resolve(target, item, 'package.json'))) {
|
||||
plugins.push(item);
|
||||
}
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
async function fsExists(path) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
exports.fsExists = fsExists;
|
||||
|
||||
async function createStoragePluginSymLink(pluginName) {
|
||||
const storagePluginsPath = resolve(process.cwd(), 'storage/plugins');
|
||||
const nodeModulesPath = process.env.NODE_MODULES_PATH; // resolve(dirname(require.resolve('@nocobase/server/package.json')), 'node_modules');
|
||||
// const nodeModulesPath = resolve(process.cwd(), 'node_modules');
|
||||
try {
|
||||
if (pluginName.startsWith('@')) {
|
||||
const [orgName] = pluginName.split('/');
|
||||
if (!(await fsExists(resolve(nodeModulesPath, orgName)))) {
|
||||
await mkdir(resolve(nodeModulesPath, orgName), { recursive: true });
|
||||
}
|
||||
}
|
||||
const link = resolve(nodeModulesPath, pluginName);
|
||||
if (await fsExists(link)) {
|
||||
await unlink(link);
|
||||
}
|
||||
await symlink(resolve(storagePluginsPath, pluginName), link);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
exports.createStoragePluginSymLink = createStoragePluginSymLink;
|
||||
|
||||
async function createStoragePluginsSymlink() {
|
||||
const storagePluginsPath = resolve(process.cwd(), 'storage/plugins');
|
||||
if (!(await fsExists(storagePluginsPath))) {
|
||||
return;
|
||||
}
|
||||
const pluginNames = await getStoragePluginNames(storagePluginsPath);
|
||||
await Promise.all(pluginNames.map((pluginName) => createStoragePluginSymLink(pluginName)));
|
||||
}
|
||||
|
||||
exports.createStoragePluginsSymlink = createStoragePluginsSymlink;
|
||||
|
||||
async function createDevPluginSymLink(pluginName) {
|
||||
const packagePluginsPath = resolve(process.cwd(), 'packages/plugins');
|
||||
const nodeModulesPath = process.env.NODE_MODULES_PATH; // resolve(dirname(require.resolve('@nocobase/server/package.json')), 'node_modules');
|
||||
try {
|
||||
if (pluginName.startsWith('@')) {
|
||||
const [orgName] = pluginName.split('/');
|
||||
if (!(await fsExists(resolve(nodeModulesPath, orgName)))) {
|
||||
await mkdir(resolve(nodeModulesPath, orgName), { recursive: true });
|
||||
}
|
||||
}
|
||||
const link = resolve(nodeModulesPath, pluginName);
|
||||
if (await fsExists(link)) {
|
||||
await unlink(link);
|
||||
}
|
||||
await symlink(resolve(packagePluginsPath, pluginName), link);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
exports.createDevPluginSymLink = createDevPluginSymLink;
|
@ -19,4 +19,6 @@ export * from './registry';
|
||||
export * from './requireModule';
|
||||
export * from './toposort';
|
||||
export * from './uid';
|
||||
export * from './url';
|
||||
|
||||
export { dayjs, lodash };
|
||||
|
@ -7,3 +7,4 @@ export * from './registry';
|
||||
export * from './requireModule';
|
||||
export * from './toposort';
|
||||
export * from './uid';
|
||||
export * from './url';
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user