Compare commits

...

7 Commits

Author SHA1 Message Date
Wells
181bfee2d2 chore(release): publish
- @music163/tango-designer@1.0.0-alpha.8
 - @music163/tango-setting-form@1.0.0-alpha.7
 - @music163/tango-ui@1.0.0-alpha.5
2024-01-23 11:13:29 +08:00
Wells
8fd085d4fd fix: update FormObject style (#91) 2024-01-23 11:11:34 +08:00
Wells
35e258b32a chore(release): publish
- @music163/tango-context@1.0.0-alpha.6
 - @music163/tango-core@1.0.0-alpha.6
 - @music163/tango-designer@1.0.0-alpha.7
 - @music163/tango-helpers@1.0.0-alpha.2
 - @music163/tango-sandbox@1.0.0-alpha.6
 - @music163/tango-setting-form@1.0.0-alpha.6
 - @music163/tango-ui@1.0.0-alpha.4
2024-01-16 15:31:02 +08:00
Wells
62d403f80a fix: 优化变量树实现 (#90)
* docs: update milestone

* fix: add new properties and methods to interfaces and models

* chore: add CI workflow for pull requests

* chore: add prebuild step to CI workflow
2024-01-16 15:28:55 +08:00
Wells
73aded3672 chore(release): publish
- @music163/tango-context@1.0.0-alpha.5
 - @music163/tango-core@1.0.0-alpha.5
 - @music163/tango-designer@1.0.0-alpha.6
 - @music163/tango-helpers@1.0.0-alpha.1
 - @music163/tango-sandbox@1.0.0-alpha.5
 - @music163/tango-setting-form@1.0.0-alpha.5
 - @music163/tango-ui@1.0.0-alpha.3
2023-12-29 17:41:58 +08:00
Wells
8c07821d93 fix: refactor VariableTree & Workspace (#83)
* fix: refactor VariableTree

* fix: update

* fix: update

* fix: update

* fix: update

* fix: refactor IVariableTreeNode

* fix: update

* fix: refactor workspace

---------

Co-authored-by: wwsun <ww.sww@outlook.com>
2023-12-29 17:30:11 +08:00
wwsun
765ea07cd5 fix: check if store value is valid 2023-12-26 15:43:20 +08:00
52 changed files with 1731 additions and 1436 deletions

49
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: CI
# Event设置为main分支的pull request事件
# 这里的main分支相当于master分支github项目新建是把main设置为默认分支我懒得改了所以就保持这样吧
on:
pull_request:
branches: main
jobs:
# 只需要定义一个job并命名为CI
CI:
runs-on: ubuntu-latest
steps:
# 拉取项目代码
- name: Checkout repository
uses: actions/checkout@v2
# 给当前环境下载node
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
# 检查缓存
# 如果key命中缓存则直接将缓存的文件还原到 path 目录,从而减少流水线运行时间
# 若 key 没命中缓存时在当前Job成功完成时将自动创建一个新缓存
- name: Cache
# 缓存命中结果会存储在steps.[id].outputs.cache-hit里该变量在继后的step中可读
id: cache-dependencies
uses: actions/cache@v3
with:
# 缓存文件目录的路径
path: |
**/node_modules
# key中定义缓存标志位的生成方式。runner.OS指当前环境的系统。外加对yarn.lock内容生成哈希码作为key值如果yarn.lock改变则代表依赖有变化。
# 这里用yarn.lock而不是package.json是因为package.json中还有version和description之类的描述项目但和依赖无关的属性
key: ${{runner.OS}}-${{hashFiles('**/yarn.lock')}}
# 安装依赖
- name: Installing Dependencies
# 如果缓存标志位没命中则执行该step。否则就跳过该step
if: steps.cache-dependencies.outputs.cache-hit != 'true'
run: yarn install
# 预构建
- name: Prebuild
run: yarn build
# 运行代码扫描
- name: Running Lint
# 通过前面章节定义的命令行执行代码扫描
run: yarn eslint
# 运行自动化测试
- name: Running Test
# 通过前面章节定义的命令行执行自动化测试
run: yarn test

View File

@@ -31,26 +31,28 @@ You can view the detailed usage guide through the following links:
- Out-of-the-box front-end low-code designer, providing flexible and easy-to-use designer React components
- Developed using TypeScript, providing complete type definition files
## 🌐 Compatibility
- Modern browsersChrome >= 80, Edge >= 80, last 2 safari versions, last 2 firefox versions
## 🎯 Milestone
```mermaid
gantt
dateFormat YYYY-MM-DD
title Tango Release Milestone
title Tango 1.0 Milestones
excludes weekends
%% (`excludes` accepts specific dates in YYYY-MM-DD format, days of the week ("sunday") or "weekends", but not the word "weekdays".)
section Builder Release
Alpha version :active, m1, 2023-08-10, 2023-08-30
Beta version : m2, 2023-09-01, 2023-09-30
1.0 RC : m3, 2023-10-01, 2023-12-15
1.0 version : m4, after m3, 10d
1.0 Alpha : m1, 2023-10-01, 2024-01-31
1.0 RC : m2, 2024-02-01, 2024-04-30
1.0 Stable : m3, 2024-04-30, 2024-12-31
```
> [!NOTE]
> Alpha versions will continue to optimize some implementation protocols and continuously integrate new features from the internal version of NetEase; RC versions will provide a stable parsing protocol and implementation; The 1.0 official version is planned to be released in Q2 2024. After the release of the 1.0 official version, we will continue to iterate on versions and gradually incorporate core capabilities such as process design, component library, and AIGC from the internal version.
## 🌐 Compatibility
- Modern browsersChrome >= 80, Edge >= 80, last 2 safari versions, last 2 firefox versions
## 💻 Development
### Environment

View File

@@ -31,26 +31,28 @@
- 开箱即用的前端低代码设计器,提供灵活易用的设计器 React 组件
- 使用 TypeScript 开发,提供完整的类型定义文件
## 🌐 兼容环境
- 现代浏览器Chrome >= 80, Edge >= 80, last 2 safari versions, last 2 firefox versions
## 🎯 里程碑
```mermaid
gantt
dateFormat YYYY-MM-DD
title Tango Release Milestone
title Tango 1.0 Milestones
excludes weekends
%% (`excludes` accepts specific dates in YYYY-MM-DD format, days of the week ("sunday") or "weekends", but not the word "weekdays".)
section Builder Release
Alpha version :active, m1, 2023-08-10, 2023-08-30
Beta version : m2, 2023-09-01, 2023-09-30
1.0 RC : m3, 2023-10-01, 2023-12-15
1.0 version : m4, after m3, 10d
1.0 Alpha : m1, 2023-10-01, 2024-01-31
1.0 RC : m2, 2024-02-01, 2024-04-30
1.0 Stable : m3, 2024-04-30, 2024-12-31
```
> [!NOTE]
> Alpha 版本会持续对一些实现协议进行优化并持续融合网易内网版本中的新增特性RC 版本将会提供基本稳定的解析协议和实现1.0 正式版本计划在 2024 年Q2发布。1.0 正式版发布后我们将会持续进行版本迭代并逐步融入内网版本中的流程设计、组件库、AIGC 等核心基础能力。
## 🌐 兼容环境
- 现代浏览器Chrome >= 80, Edge >= 80, last 2 safari versions, last 2 firefox versions
## 💻 开发
### 推荐开发环境

View File

@@ -28,6 +28,41 @@ export function Basic() {
);
}
const code = `
function foo() {
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
}
`;
export function Readonly() {
return <InputCode shape="inset" readOnly value={code} />;
}
export function Inset() {
return <InputCode shape="inset" />;
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { VariableTree } from '@music163/tango-designer/lib/cjs/components';
import { Box } from 'coral-system';
export default {
title: 'Designer/VariableTree',
};
const data = [
{
title: 'Stores',
key: 'stores',
selectable: false,
children: [
{
title: 'user',
key: 'stores.user',
children: [
{
title: 'name',
key: 'stores.user.name',
raw: '"Tom"',
},
{
title: 'age',
key: 'stores.user.age',
raw: '18',
},
],
},
{
title: 'book',
key: 'stores.book',
children: [
{
title: 'name',
key: 'stores.book.name',
raw: '"JavaScript 高级程序设计"',
},
{
title: 'price',
key: 'stores.book.price',
raw: '99.99',
},
],
},
],
},
{
title: 'Services',
key: 'services',
selectable: false,
children: [
{
title: 'add',
key: 'services.add',
},
{
title: 'multiply',
key: 'services.multiply',
},
],
},
];
export function Basic() {
return (
<Box width={600} border="solid">
<VariableTree dataSource={data} />
</Box>
);
}

View File

@@ -62,13 +62,13 @@ const config = {
({
// Replace with your project's social card
image: 'img/social-card.png',
announcementBar: {
id: 'notion_alert',
content: '🏗 当前版本为测试版请暂时不要用于生产环境正式版将于2023年Q4发布',
backgroundColor: 'var(--ifm-color-primary-contrast-background)',
textColor: 'var(--ifm-color-primary-contrast-foreground)',
isCloseable: false,
},
// announcementBar: {
// id: 'notion_alert',
// content: '🏗 当前版本为测试版请暂时不要用于生产环境正式版将于2023年Q4发布',
// backgroundColor: 'var(--ifm-color-primary-contrast-background)',
// textColor: 'var(--ifm-color-primary-contrast-foreground)',
// isCloseable: false,
// },
navbar: {
title: '',
logo: {

View File

@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.0.0-alpha.6](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0-alpha.5...@music163/tango-context@1.0.0-alpha.6) (2024-01-16)
**Note:** Version bump only for package @music163/tango-context
# [1.0.0-alpha.5](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0-alpha.4...@music163/tango-context@1.0.0-alpha.5) (2023-12-29)
### Bug Fixes
- refactor VariableTree & Workspace ([#83](https://github.com/netease/tango/issues/83)) ([8c07821](https://github.com/netease/tango/commit/8c07821d93cea4dfc43f81ca948b845176821184))
# [1.0.0-alpha.4](https://github.com/netease/tango/compare/@music163/tango-context@1.0.0-alpha.3...@music163/tango-context@1.0.0-alpha.4) (2023-12-26)
**Note:** Version bump only for package @music163/tango-context

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-context",
"version": "1.0.0-alpha.4",
"version": "1.0.0-alpha.6",
"description": "react context for tango-apps",
"keywords": [
"react",
@@ -31,8 +31,8 @@
"react": ">= 16.8"
},
"dependencies": {
"@music163/tango-core": "^1.0.0-alpha.4",
"@music163/tango-helpers": "^1.0.0-alpha.0",
"@music163/tango-core": "^1.0.0-alpha.6",
"@music163/tango-helpers": "^1.0.0-alpha.2",
"mobx-react-lite": "4.0.5"
},
"publishConfig": {

View File

@@ -1,12 +1,5 @@
import type { Engine } from '@music163/tango-core';
import { createContext } from '@music163/tango-helpers';
interface CustomVariableData {
key: string;
title: string;
children?: CustomVariableData[];
[key: string]: any;
}
import { IVariableTreeNode, createContext } from '@music163/tango-helpers';
export interface ITangoEngineContext {
/**
@@ -17,8 +10,8 @@ export interface ITangoEngineContext {
* 自定义配置数据
*/
config?: {
customActionVariables?: CustomVariableData[];
customExpressionVariables?: CustomVariableData[];
customActionVariables?: IVariableTreeNode[];
customExpressionVariables?: IVariableTreeNode[];
};
}
@@ -39,23 +32,25 @@ export const useDesigner = () => {
export const useWorkspaceData = () => {
const ctx = useTangoEngine();
const workspace = useWorkspace();
const modelVariables: any[] = []; // 绑定变量列表
const storeActionVariables: any[] = []; // 模型中的所有 actions
const storeVariables: any[] = []; // 模型中的所有变量
const serviceVariables: any[] = []; // 服务中的所有变量
const modelVariables: IVariableTreeNode[] = []; // 绑定变量列表
const storeActionVariables: IVariableTreeNode[] = []; // 模型中的所有 actions
const storeVariables: IVariableTreeNode[] = []; // 模型中的所有变量
const serviceVariables: IVariableTreeNode[] = []; // 服务中的所有变量
Object.values(workspace.storeModules).forEach((file) => {
const prefix = `stores.${file.name}`;
const states = file.states.map((item) => ({
const states: IVariableTreeNode[] = file.states.map((item) => ({
title: item.name,
key: `${prefix}.${item.name}`,
raw: item.code,
showRemoveButton: true,
}));
const actions = file.actions.map((item) => ({
const actions: IVariableTreeNode[] = file.actions.map((item) => ({
title: item.name,
key: `${prefix}.${item.name}`,
type: 'function',
raw: item.code,
showRemoveButton: true,
}));
modelVariables.push({
@@ -63,6 +58,7 @@ export const useWorkspaceData = () => {
key: prefix,
selectable: false,
children: states,
showAddButton: true,
});
storeActionVariables.push({
@@ -77,8 +73,7 @@ export const useWorkspaceData = () => {
key: prefix,
selectable: false,
children: [...states, ...actions],
showAddChildIcon: true,
showRemoveIcon: true,
showAddButton: true,
});
});
@@ -88,10 +83,12 @@ export const useWorkspaceData = () => {
title: file.name,
key: prefix,
selectable: false,
showAddButton: true,
children: Object.keys(file.serviceFunctions || {}).map((key) => ({
title: key,
key: [prefix, key].join('.'),
type: 'function',
showRemoveButton: true,
})),
});
});
@@ -102,7 +99,7 @@ export const useWorkspaceData = () => {
value: item.path,
}));
let actionVariables: CustomVariableData[] = [
let actionVariables: IVariableTreeNode[] = [
buildVariableOptions('数据模型', '$stores', storeActionVariables),
buildVariableOptions('服务函数', '$services', serviceVariables),
];
@@ -111,7 +108,7 @@ export const useWorkspaceData = () => {
actionVariables = actionVariables.concat(ctx.config?.customActionVariables);
}
let expressionVariables: CustomVariableData[] = [
let expressionVariables: IVariableTreeNode[] = [
buildVariableOptions('数据模型', '$stores', storeVariables),
buildVariableOptions('服务函数', '$services', serviceVariables),
];
@@ -129,7 +126,7 @@ export const useWorkspaceData = () => {
};
};
function buildVariableOptions(title: string, key: string, children: any[]) {
function buildVariableOptions(title: string, key: string, children: IVariableTreeNode[]) {
return {
key,
title,

View File

@@ -3,6 +3,19 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.0.0-alpha.6](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0-alpha.5...@music163/tango-core@1.0.0-alpha.6) (2024-01-16)
### Bug Fixes
- 优化变量树实现 ([#90](https://github.com/netease/tango/issues/90)) ([62d403f](https://github.com/netease/tango/commit/62d403f80a5ad08c216bc0c035ffc12c2cf329d2))
# [1.0.0-alpha.5](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0-alpha.4...@music163/tango-core@1.0.0-alpha.5) (2023-12-29)
### Bug Fixes
- check if store value is valid ([765ea07](https://github.com/netease/tango/commit/765ea07cd5be6528e3b326e4ecb163647774d9e5))
- refactor VariableTree & Workspace ([#83](https://github.com/netease/tango/issues/83)) ([8c07821](https://github.com/netease/tango/commit/8c07821d93cea4dfc43f81ca948b845176821184))
# [1.0.0-alpha.4](https://github.com/netease/tango/compare/@music163/tango-core@1.0.0-alpha.3...@music163/tango-core@1.0.0-alpha.4) (2023-12-26)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-core",
"version": "1.0.0-alpha.4",
"version": "1.0.0-alpha.6",
"description": "tango core",
"author": "wwsun <ww.sun@outlook.com>",
"homepage": "",
@@ -28,7 +28,7 @@
"@babel/parser": "^7.23.5",
"@babel/traverse": "^7.23.5",
"@babel/types": "^7.23.5",
"@music163/tango-helpers": "^1.0.0-alpha.0",
"@music163/tango-helpers": "^1.0.0-alpha.2",
"@types/babel__generator": "^7.6.7",
"@types/babel__traverse": "^7.20.4",
"mobx": "6.12.0",

View File

@@ -44,6 +44,11 @@ export function isValidCode(code: string) {
/**
* 检测代码是否是合法的表达式代码
* 表达式是一组代码的集合,它返回一个值;每一个合法的表达式都能计算成某个值
* 只要你输入这段代码,可以形成一个值,就算是表达式
* @example x = 1; // 1
* @example 1=1
* @example 'hello'
* @example { foo: 'bar' }
* @param code
* @returns
*/

View File

@@ -29,7 +29,6 @@ import type {
IStorePropertyData,
ITangoViewNodeData,
IImportDeclarationPayload,
IServiceFunctionPayload,
InsertChildPositionType,
IImportSpecifierData,
IExportSpecifierData,
@@ -998,7 +997,7 @@ export function deleteServiceConfigFromServiceFile(ast: t.File, serviceFunctionN
* @param payload
* @returns
*/
export function serviceConfig2Node(payload: IServiceFunctionPayload) {
export function serviceConfig2Node(payload: object) {
return object2node(payload, (value, key) => {
if (key === 'formatter' && value) {
return code2expression(value);
@@ -1007,10 +1006,7 @@ export function serviceConfig2Node(payload: IServiceFunctionPayload) {
});
}
export function updateServiceConfigToServiceFile(
ast: t.File,
config: { [key: string]: IServiceFunctionPayload },
) {
export function updateServiceConfigToServiceFile(ast: t.File, config: Dict<object>) {
traverse(ast, {
CallExpression(path) {
const calleeName = keyNode2value(path.node.callee) as string;

View File

@@ -8,7 +8,6 @@ import {
InsertChildPositionType,
ITangoConfigPackages,
IPageConfigData,
IServiceFunctionPayload,
IImportSpecifierSourceData,
IImportSpecifierData,
} from '../types';
@@ -204,20 +203,23 @@ export interface IWorkspace {
// ----------------- 服务函数文件操作 -----------------
getServiceFunction?: (serviceKey: string) => object;
listServiceFunctions?: () => Record<string, object>;
removeServiceFunction?: (serviceName: string, modName?: string) => void;
addServiceFunction?: (
payload: IServiceFunctionPayload | IServiceFunctionPayload[],
modName?: string,
) => void;
updateServiceFunction?: (payload: IServiceFunctionPayload, modName?: string) => void;
updateServiceBaseConfig?: (IServiceFunctionPayload: object, modName?: string) => void;
getServiceFunction?: (serviceKey: string) => {
name: string;
moduleName: string;
config: Dict<object>;
};
listServiceFunctions?: () => Dict<object>;
removeServiceFunction?: (serviceKey: string) => void;
addServiceFunction?: (serviceName: string, config: Dict, modName?: string) => void;
addServiceFunctions?: (configs: Dict<object>, modName?: string) => void;
updateServiceFunction?: (serviceName: string, payload: Dict, modName?: string) => void;
updateServiceBaseConfig?: (config: Dict, modName?: string) => void;
// ----------------- 状态管理文件操作 -----------------
addStoreState?: (storeName: string, stateName: string, initValue: string) => void;
removeStoreState?: (storeName: string, stateName: string) => void;
removeStoreModule?: (storeName: string) => void;
removeStoreVariable?: (variablePath: string) => void;
updateStoreVariable?: (variablePath: string, code: string) => void;
// ----------------- 视图文件操作 -----------------
@@ -256,10 +258,6 @@ export interface IWorkspace {
removeBizComp?: (name: string) => void;
// ----------------- 其他操作 -----------------
updateModuleCodeByVariablePath?: (variablePath: string, code: string) => void;
// ----------------- getter -----------------
get activeViewModule(): IViewFile;

View File

@@ -7,7 +7,7 @@ import {
deleteServiceConfigFromServiceFile,
updateBaseConfigToServiceFile,
} from '../helpers';
import { IFileConfig, IServiceFunctionPayload } from '../types';
import { IFileConfig } from '../types';
import { IWorkspace } from './interfaces';
import { TangoModule } from './module';
@@ -67,25 +67,23 @@ export class TangoServiceModule extends TangoModule {
}
}
addServiceFunction(payload: IServiceFunctionPayload) {
const { name, ...rest } = payload;
this.ast = updateServiceConfigToServiceFile(this.ast, { [name]: clone(rest, false) });
addServiceFunction(name: string, config: object) {
this.ast = updateServiceConfigToServiceFile(this.ast, { [name]: clone(config, false) });
return this;
}
addServiceFunctions(payloads: IServiceFunctionPayload[]) {
const config = payloads.reduce((acc, cur) => {
const { name, ...rest } = cur;
acc[name] = clone(rest, false);
return acc;
}, {});
this.ast = updateServiceConfigToServiceFile(this.ast, config);
/**
* 批量添加服务函数
* @param configs { [name: string]: object }
* @returns
*/
addServiceFunctions(configs: Dict<object>) {
this.ast = updateServiceConfigToServiceFile(this.ast, configs);
return this;
}
updateServiceFunction(payload: IServiceFunctionPayload) {
const { name, ...rest } = payload;
this.ast = updateServiceConfigToServiceFile(this.ast, { [name]: clone(rest, false) });
updateServiceFunction(name: string, payload: object) {
this.ast = updateServiceConfigToServiceFile(this.ast, { [name]: clone(payload, false) });
return this;
}

View File

@@ -2,9 +2,13 @@ import { action, computed, makeObservable, observable } from 'mobx';
import { JSXElement } from '@babel/types';
import {
ComponentPrototypeType,
Dict,
hasFileExtension,
isStoreVariablePath,
isString,
logger,
parseServiceVariablePath,
parseStoreVariablePath,
uniq,
} from '@music163/tango-helpers';
import {
@@ -22,13 +26,7 @@ import { TangoNode } from './node';
import { TangoJsModule } from './module';
import { TangoFile, TangoJsonFile, TangoLessFile } from './file';
import { IWorkspace } from './interfaces';
import {
IFileConfig,
FileType,
ITangoConfigPackages,
IPageConfigData,
IServiceFunctionPayload,
} from '../types';
import { IFileConfig, FileType, ITangoConfigPackages, IPageConfigData } from '../types';
import { SelectSource } from './select-source';
import { DragSource } from './drag-source';
import { TangoRouteModule } from './route-module';
@@ -599,15 +597,23 @@ export class Workspace extends EventTarget implements IWorkspace {
}
/**
* 根据变量路径更新模块内容
* TODO: 改名,不直观
* 根据变量路径删除状态变量
* @param variablePath
*/
removeStoreVariable(variablePath: string) {
const { storeName, variableName } = parseStoreVariablePath(variablePath);
this.removeStoreState(storeName, variableName);
}
/**
* 根据变量路径更新状态变量的值
* @param variablePath 变量路径
* @param code 变量代码
*/
updateModuleCodeByVariablePath(variablePath: string, code: string) {
if (/^stores\.\w+\.\w+$/.test(variablePath)) {
const [, storeName, stateName] = variablePath.split('.');
this.storeModules[storeName]?.updateState(stateName, code).update();
updateStoreVariable(variablePath: string, code: string) {
if (isStoreVariablePath(variablePath)) {
const { storeName, variableName } = parseStoreVariablePath(variablePath);
this.storeModules[storeName]?.updateState(variableName, code).update();
}
}
@@ -618,7 +624,7 @@ export class Workspace extends EventTarget implements IWorkspace {
* @returns
*/
getServiceFunction(serviceKey: string) {
const { name, moduleName } = this.parseServiceKey(serviceKey);
const { name, moduleName } = parseServiceVariablePath(serviceKey);
if (!name) {
return;
}
@@ -632,9 +638,10 @@ export class Workspace extends EventTarget implements IWorkspace {
/**
* 获取服务函数的列表
* @returns 返回服务函数的列表 { [serviceKey: string]: Dict }
*/
listServiceFunctions() {
const ret: Record<string, object> = {};
const ret: Record<string, Dict> = {};
Object.keys(this.serviceModules).forEach((moduleName) => {
const module = this.serviceModules[moduleName];
Object.keys(module.serviceFunctions).forEach((name) => {
@@ -648,36 +655,34 @@ export class Workspace extends EventTarget implements IWorkspace {
/**
* 更新服务函数
*/
updateServiceFunction(payload: IServiceFunctionPayload, moduleName = 'index') {
this.serviceModules[moduleName].updateServiceFunction(payload).update();
updateServiceFunction(serviceName: string, payload: Dict, moduleName = 'index') {
this.serviceModules[moduleName].updateServiceFunction(serviceName, payload).update();
}
/**
* 新增服务函数,支持批量添加
*/
addServiceFunction(
payload: IServiceFunctionPayload | IServiceFunctionPayload[],
moduleName = 'index',
) {
if (Array.isArray(payload)) {
this.serviceModules[moduleName]?.addServiceFunctions(payload).update();
} else {
this.serviceModules[moduleName]?.addServiceFunction(payload).update();
}
addServiceFunction(name: string, config: Dict, moduleName = 'index') {
this.serviceModules[moduleName]?.addServiceFunction(name, config).update();
}
addServiceFunctions(configs: Dict<Dict>, modName = 'index') {
this.serviceModules[modName]?.addServiceFunctions(configs).update();
}
/**
* 删除服务函数
* @param name
*/
removeServiceFunction(name: string, moduleName = 'index') {
removeServiceFunction(serviceKey: string) {
const { moduleName, name } = parseServiceVariablePath(serviceKey);
this.serviceModules[moduleName]?.deleteServiceFunction(name).update();
}
/**
* 更新服务的基础配置
*/
updateServiceBaseConfig(config: object, moduleName = 'index') {
updateServiceBaseConfig(config: Dict, moduleName = 'index') {
this.serviceModules[moduleName]?.updateBaseConfig(config).update();
}
@@ -1209,40 +1214,4 @@ export class Workspace extends EventTarget implements IWorkspace {
logger.error('copyFiles failed, source: %s, target: %s', sourceFilePath, targetFilePath);
}
}
/**
* 解析 serviceKey
* @param serviceKey
* @returns
*
* @example services.list => { moduleName: 'index', name: 'list' }
* @example services.sub.list => { moduleName: 'sub', name: 'list' }
* @example foo => undefined
*/
private parseServiceKey(serviceKey: string) {
const parts = serviceKey.split('.');
if (parts[0] !== 'services') {
return {};
}
let moduleName = 'index';
let name = '';
switch (parts.length) {
case 2: {
name = parts[1];
break;
}
case 3: {
moduleName = parts[1];
name = parts[2];
break;
}
default:
break;
}
return {
moduleName,
name,
};
}
}

View File

@@ -145,17 +145,6 @@ export interface IImportDeclarationPayload {
sourcePath: string;
}
/**
* 服务函数参数类型
*/
export interface IServiceFunctionPayload {
[key: string]: any;
/**
* 服务函数名
*/
name: string;
}
/**
* Store 属性类型
*/

View File

@@ -15,6 +15,8 @@ describe('ast helpers', () => {
it('isValidExpression', () => {
expect(isValidExpressionCode('() => { }')).toBeTruthy();
expect(isValidExpressionCode('1')).toBeTruthy();
expect(isValidExpressionCode('a = 1')).toBeTruthy();
expect(isValidExpressionCode('1 + 1')).toBeTruthy();
expect(isValidExpressionCode('"hello"')).toBeTruthy();
expect(isValidExpressionCode('false')).toBeTruthy();
expect(isValidExpressionCode('{ bizId: "vip", type: "category" }')).toBeTruthy();

View File

@@ -131,7 +131,7 @@ describe('string helpers', () => {
);
expect(getRelativePath('/src/pages/index.js', '/src/components')).toEqual('../components');
expect(getRelativePath('/src/pages/index.js', '/src/components/input.js')).toEqual(
'../components/index.js',
'../components/input.js',
);
});

View File

@@ -3,6 +3,23 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.0.0-alpha.8](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.7...@music163/tango-designer@1.0.0-alpha.8) (2024-01-23)
**Note:** Version bump only for package @music163/tango-designer
# [1.0.0-alpha.7](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.6...@music163/tango-designer@1.0.0-alpha.7) (2024-01-16)
### Bug Fixes
- 优化变量树实现 ([#90](https://github.com/netease/tango/issues/90)) ([62d403f](https://github.com/netease/tango/commit/62d403f80a5ad08c216bc0c035ffc12c2cf329d2))
# [1.0.0-alpha.6](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.5...@music163/tango-designer@1.0.0-alpha.6) (2023-12-29)
### Bug Fixes
- check if store value is valid ([765ea07](https://github.com/netease/tango/commit/765ea07cd5be6528e3b326e4ecb163647774d9e5))
- refactor VariableTree & Workspace ([#83](https://github.com/netease/tango/issues/83)) ([8c07821](https://github.com/netease/tango/commit/8c07821d93cea4dfc43f81ca948b845176821184))
# [1.0.0-alpha.5](https://github.com/netease/tango/compare/@music163/tango-designer@1.0.0-alpha.4...@music163/tango-designer@1.0.0-alpha.5) (2023-12-26)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-designer",
"version": "1.0.0-alpha.5",
"version": "1.0.0-alpha.8",
"description": "lowcode designer",
"keywords": [
"react"
@@ -33,12 +33,12 @@
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@music163/request": "^0.1.2",
"@music163/tango-context": "^1.0.0-alpha.4",
"@music163/tango-core": "^1.0.0-alpha.4",
"@music163/tango-helpers": "^1.0.0-alpha.0",
"@music163/tango-sandbox": "^1.0.0-alpha.4",
"@music163/tango-setting-form": "^1.0.0-alpha.4",
"@music163/tango-ui": "^1.0.0-alpha.2",
"@music163/tango-context": "^1.0.0-alpha.6",
"@music163/tango-core": "^1.0.0-alpha.6",
"@music163/tango-helpers": "^1.0.0-alpha.2",
"@music163/tango-sandbox": "^1.0.0-alpha.6",
"@music163/tango-setting-form": "^1.0.0-alpha.7",
"@music163/tango-ui": "^1.0.0-alpha.5",
"antd": "^4.24.2",
"cash-dom": "^8.1.2",
"classnames": "^2.3.2",

View File

@@ -1,3 +1,4 @@
export * from './drag-box';
export * from './input-kv';
export * from './variable-tree';
export * from './variable-tree-modal';

View File

@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import { ModalProps, Modal } from 'antd';
import { Box } from 'coral-system';
import { IVariableTreeNode, noop, useBoolean } from '@music163/tango-helpers';
import { VariableTree, VariableTreeProps } from './variable-tree';
interface VariableTreeModalProps extends VariableTreeProps {
trigger?: React.ReactElement;
title?: ModalProps['title'];
modalProps?: ModalProps;
}
export function VariableTreeModal({
trigger,
title,
modalProps,
onSelect = noop,
...rest
}: VariableTreeModalProps) {
const [node, setNode] = useState<IVariableTreeNode>();
const [visible, { on, off }] = useBoolean(false);
return (
<Box>
{React.cloneElement(trigger, { onClick: on })}
<Modal
title={title}
open={visible}
onCancel={off}
okButtonProps={{
disabled: !node,
}}
onOk={() => {
if (node) {
onSelect(node);
off();
}
}}
width="60%"
{...modalProps}
>
<VariableTree height={400} onSelect={setNode} {...rest} />
</Modal>
</Box>
);
}

View File

@@ -1,638 +0,0 @@
import React, { useMemo, useState } from 'react';
import {
Tree,
Button,
Form,
Input,
Space,
ModalProps,
Modal,
Radio,
Empty,
Popconfirm,
Tooltip,
Alert,
} from 'antd';
import { Box, Text, css } from 'coral-system';
import {
PlusOutlined,
FunctionOutlined,
DeleteOutlined,
CopyOutlined,
EyeOutlined,
} from '@ant-design/icons';
import {
filterTreeData,
isFunction,
isNil,
isString,
noop,
useBoolean,
} from '@music163/tango-helpers';
import {
Panel,
InputCode,
Search,
JsonView,
JsonViewProps,
CopyClipboard,
} from '@music163/tango-ui';
import { isValidExpressionCode } from '@music163/tango-core';
export interface IVariableTreeNode {
/**
* 唯一标识符
*/
key: string;
/**
* 标题
*/
title?: string;
/**
* 是否可选中
*/
selectable?: boolean;
/**
* 展示添加子节点的图标
*/
showAddChildIcon?: boolean;
/**
* 展示删除删除图标
*/
showDeleteIcon?: boolean;
/**
* 结点类型,用来展示图标
*/
type?: 'function' | 'property';
/**
* 定义的原始值
*/
raw?: any;
/**
* 子结点
*/
children?: IVariableTreeNode[];
[key: string]: any;
}
type VariableModelType = 'preview' | 'define' | 'add';
export interface EditableVariableTreeProps {
/**
* 允许的变量面板
*/
modes?: VariableModelType[];
/**
* 默认的变量面板
*/
defaultMode?: VariableModelType;
/**
* 数据源
*/
dataSource?: VariableTreeProps['dataSource'];
/**
* 点击添加变量的回调
*/
onAddVariable?: AddNodeFormProps['onSubmit'];
/**
* 预览值取值函数
*/
getPreviewValue?: (node: IVariableTreeNode) => unknown;
/**
* 选择列表项时的回调
*/
onSelect?: (data: IVariableTreeNode) => void;
/**
* 保存结点定义的回调
*/
onSave?: NodeDefineProps['onSave'];
/**
* 删除结点定义的回调
*/
onDeleteVariable?: (storeName: string, stateName: string) => void;
/**
* 删除模型时的回调
*/
onDeleteStore?: (storeName: string) => void;
/**
* 高度
*/
height?: number | string;
/**
* 搜索框的样式
*/
searchStyle?: React.CSSProperties;
/**
* 搜索框的后置结点
*/
searchAddonAfter?: React.ReactNode;
}
const previewOptions = [
{ label: '运行时', value: 'preview' },
{ label: '定义', value: 'define' },
];
export function EditableVariableTree({
height,
searchAddonAfter,
searchStyle,
dataSource,
onAddVariable = noop,
onDeleteVariable = noop,
onDeleteStore = noop,
getPreviewValue = noop,
onSelect = noop,
onSave = noop,
modes = ['add', 'preview', 'define'],
defaultMode = 'preview',
}: EditableVariableTreeProps) {
const [keyword, setKeyword] = useState('');
const [node, setNode] = useState<IVariableTreeNode>();
const [mode, setMode] = useState<EditableVariableTreeProps['defaultMode']>(defaultMode);
const hasPanelSwitch = modes.includes('define') && modes.includes('preview');
const treeData = useMemo(() => {
const pattern = new RegExp(keyword, 'ig');
return keyword
? filterTreeData(dataSource, (leaf) => pattern.test(leaf.title), 'children', true)
: dataSource;
}, [keyword, dataSource]);
return (
<Box className="EditableVariableTree" display="flex" height={height} position="relative">
<Box p="l" width="40%" height="100%" overflow="auto">
<Box mb="m" position="sticky" top="0" bg="white" zIndex={2}>
<Input.Group compact>
<Search
placeholder="请输入变量名"
onChange={(val) => setKeyword(val?.trim())}
style={searchStyle}
/>
{searchAddonAfter}
</Input.Group>
</Box>
<VariableTree
dataSource={treeData}
showViewIcon
onSelect={(item) => {
setNode(item);
mode === 'add' && setMode(defaultMode);
onSelect(item);
}}
onView={(item) => {
setNode(item);
mode === 'add' && setMode(defaultMode);
}}
onAdd={(item) => {
setNode(item);
setMode('add');
}}
onRemove={(item) => {
const [type, storeName] = item.key.split('.');
setNode(null);
onDeleteStore(storeName);
}}
/>
</Box>
{node ? (
<Box width="60%" height="100%" position="sticky" top="0">
{node.help && (
<Alert
type="info"
message={`使用说明:${node.help}`}
closable
style={{ marginBottom: 12 }}
/>
)}
<Panel
shape="solid"
title={
{
preview: '变量值预览',
add: '添加变量',
define: '变量定义',
}[mode]
}
extra={
hasPanelSwitch && mode !== 'add' ? (
<Radio.Group
optionType="button"
buttonStyle="solid"
size="small"
value={mode}
onChange={(e) => setMode(e.target.value)}
options={previewOptions}
/>
) : null
}
height="100%"
bodyProps={{ px: 'm' }}
>
{mode === 'preview' && (
<ValuePreview
value={getPreviewValue(node)}
onSelect={(valuePath) => {
return ['tango', node.key.replaceAll('.', '?.'), valuePath].join('.');
}}
/>
)}
{mode === 'define' && (
<NodeDefineForm
node={node}
onSave={onSave}
onDelete={(item) => {
const [type, storeName, stateName] = item.key.split('.');
onDeleteVariable(storeName, stateName);
setNode(null);
}}
/>
)}
{mode === 'add' && (
<AddNodeForm
parentNode={node}
onCancel={() => {
setMode(defaultMode);
setNode(null);
}}
onSubmit={(storeName, data) => {
onAddVariable(storeName, data);
setMode(defaultMode);
setNode(null);
}}
/>
)}
</Panel>
</Box>
) : (
<Panel shape="solid" title="提示" flex="1" position="sticky" top="0">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请从左侧列表中选择一个变量" />
</Panel>
)}
</Box>
);
}
interface EditableVariableTreeModalProps extends EditableVariableTreeProps {
trigger?: React.ReactElement;
title?: ModalProps['title'];
modalProps?: ModalProps;
}
export function EditableVariableTreeModal({
trigger,
title,
modalProps,
onSelect = noop,
...rest
}: EditableVariableTreeModalProps) {
const [node, setNode] = useState<IVariableTreeNode>();
const [visible, { on, off }] = useBoolean(false);
return (
<Box>
{React.cloneElement(trigger, { onClick: on })}
<Modal
title={title}
open={visible}
onCancel={off}
okButtonProps={{
disabled: !node,
}}
onOk={() => {
if (node) {
onSelect(node);
off();
}
}}
width="60%"
{...modalProps}
>
<EditableVariableTree height={480} onSelect={setNode} {...rest} />
</Modal>
</Box>
);
}
const varTreeStyle = css`
overflow: auto;
.ant-tree {
font-family: Consolas, Menlo, Courier, monospace;
}
.ant-tree-node-content-wrapper.ant-tree-node-content-wrapper-normal {
width: calc(100% - 50px);
}
.ant-tree .ant-tree-treenode {
padding: 0;
}
.ant-tree.ant-tree-directory .ant-tree-treenode::before {
bottom: 0;
}
.ant-tree-indent-unit {
width: 12px;
}
.anticon-function {
margin-left: 4px;
color: var(--tango-colors-text2);
}
`;
interface VariableTreeProps {
dataSource?: IVariableTreeNode[];
onSelect?: (data: IVariableTreeNode) => void;
onAdd?: (data: IVariableTreeNode) => void;
onRemove?: (data: IVariableTreeNode) => void;
onCopy?: (data: IVariableTreeNode) => void;
onView?: (data: IVariableTreeNode) => void;
showDeleteIcon?: boolean;
showViewIcon?: boolean;
}
export function VariableTree({
dataSource = [],
onSelect = noop,
onAdd = noop,
onRemove = noop,
onCopy = noop,
onView = noop,
showDeleteIcon = false,
showViewIcon = false,
}: VariableTreeProps) {
return (
<Box className="VariableTree" css={varTreeStyle}>
<Tree
blockNode
showIcon={false}
defaultExpandAll
treeData={dataSource}
onSelect={(keys, detail) => {
onSelect(detail.node);
}}
titleRender={(node) => {
const isLeaf = !node.children;
if (isLeaf) {
const isDeletable = node.showDeleteIcon ?? showDeleteIcon;
return (
<Box display="flex" justifyContent="space-between" alignItems="center">
<Text flex="1" truncated>
{node.title}
{node.type === 'function' && <FunctionOutlined />}
</Text>
<Box flex="0 0 72px" textAlign="right">
{isDeletable && (
<Popconfirm
title="确认删除吗?该操作会导致引用此模型的代码报错,请谨慎操作!"
onConfirm={() => onRemove(node)}
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
}}
/>
</Popconfirm>
)}
<CopyClipboard text={`tango.${node.key.replaceAll('.', '?.')}`}>
{({ copied, onClick }) => {
const label = copied ? '已复制' : '复制变量路径';
return (
<Tooltip title={label}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation();
onClick();
onCopy(node);
}}
/>
</Tooltip>
);
}}
</CopyClipboard>
{showViewIcon && (
<Tooltip title="查看变量详情">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
onView(node);
}}
/>
</Tooltip>
)}
</Box>
</Box>
);
}
return (
<Box display="flex" alignItems="center" justifyContent="space-between">
<Text mr="m">{node.title}</Text>
<Box>
{node.showAddChildIcon && (
<Tooltip title={`${node.title} 中添加变量`}>
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation();
onAdd(node);
}}
/>
</Tooltip>
)}
</Box>
</Box>
);
}}
/>
</Box>
);
}
interface NodePreviewProps {
value?: unknown;
/**
* 选择预览结点的回调
*/
onSelect?: JsonViewProps['onCopy'];
}
export function ValuePreview({ value, onSelect }: NodePreviewProps) {
if (isNil(value)) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂时没有可预览的数据" />;
}
if (isFunction(value)) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂不支持预览函数" />;
}
if (typeof value === 'object') {
return <JsonView src={value} enableCopy onCopy={onSelect} />;
}
return (
<InputCode
shape="inset"
value={isString(value) ? `"${value}"` : String(value)}
editable={false}
/>
);
}
interface NodeDefineProps {
node: IVariableTreeNode;
onSave?: (code: string, node: IVariableTreeNode) => void;
onDelete?: (node: IVariableTreeNode) => void;
}
/**
* 变量值定义面板
*/
function NodeDefineForm({ node, onSave = noop, onDelete = noop }: NodeDefineProps) {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const [editable, { on, off }] = useBoolean();
if (isNil(node.raw)) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="缺失定义数据" />;
}
return (
<Box>
<InputCode
shape="inset"
value={node.raw}
onChange={(nextValue) => setValue(nextValue)}
editable={editable}
onBlur={() => {
if (!isValidExpressionCode(value)) {
setError('代码格式错误,请检查代码语法!');
} else {
setError('');
}
}}
/>
<Box color="red" my="m">
{error}
</Box>
<Box mt="l">
{editable ? (
<Space>
<Button
onClick={() => {
off();
setError('');
}}
>
</Button>
<Button
type="primary"
onClick={() => {
if (error) {
return;
}
onSave(value, node);
off();
}}
>
</Button>
</Space>
) : (
<Space>
<Button onClick={on}></Button>
<Popconfirm
title="确认删除此变量吗?该操作会导致引用该变量的代码报错!"
onConfirm={() => {
onDelete(node);
}}
>
<Button danger></Button>
</Popconfirm>
</Space>
)}
</Box>
</Box>
);
}
interface AddNodeFormProps {
parentNode: IVariableTreeNode;
onCancel?: React.MouseEventHandler<HTMLButtonElement>;
onSubmit?: (storeName: string, data: { name: string; initialValue: string }) => void;
}
function AddNodeForm({ parentNode, onCancel, onSubmit }: AddNodeFormProps) {
return (
<Form
layout="vertical"
autoComplete="off"
validateTrigger="onBlur"
onFinish={(values) => {
if (onSubmit && parentNode) {
const [type, storeName] = parentNode.key.split('.');
onSubmit(storeName, values);
}
}}
>
<Form.Item label="所属模型">
<Input value={parentNode?.key} disabled />
</Form.Item>
<Form.Item
label="变量名"
name="name"
rules={[
{ required: true },
{ pattern: /^\w+$/, message: '非法的变量标识符' },
{
validator(_, value) {
const found = parentNode.children.find((item) => item.title === value);
return found
? Promise.reject(new Error('和已有变量名冲突,请换一个名字!'))
: Promise.resolve();
},
},
]}
>
<Input placeholder="请输入变量名" />
</Form.Item>
<Form.Item
label="初值"
name="initialValue"
rules={[
{ required: true },
{
validator(_, value) {
return isValidExpressionCode(value)
? Promise.resolve()
: Promise.reject(new Error('代码存在语法错误,请输入合法的代码片段!'));
},
},
]}
>
<InputCode shape="inset" placeholder="请输入初值" />
</Form.Item>
<Form.Item>
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
);
}

View File

@@ -0,0 +1,182 @@
import { isValidFunctionCode, url2serviceName } from '@music163/tango-helpers';
import { InputCode } from '@music163/tango-ui';
import { Button, Dropdown, Form, FormProps, Input, Select, Space } from 'antd';
import { Box } from 'coral-system';
import React, { useState } from 'react';
interface AddServiceFormProps extends FormProps {
initialValues?: object;
onSubmit: (values: object) => void;
onCancel: () => void;
serviceModules: object[];
serviceNames: string[];
}
export function AddServiceForm({
initialValues,
onSubmit,
onCancel,
serviceModules,
serviceNames,
...formProps
}: AddServiceFormProps) {
const isModifyMode = !!initialValues?.['name']; // 是否为更新模式
const [disabled, setDisabled] = useState(isModifyMode);
const [form] = Form.useForm();
return (
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 17 }}
colon={false}
layout="horizontal"
initialValues={initialValues}
onFinish={(values) => {
onSubmit(values);
}}
{...formProps}
>
<Form.Item
label="命名空间"
name="moduleName"
rules={[
{
required: true,
},
]}
>
<Select options={serviceModules} disabled={disabled || isModifyMode} />
</Form.Item>
<Form.Item
label="方法名"
name="name"
rules={[
{ required: true },
{ pattern: /^[a-z]\w+$/, message: '请输入合法的方法名称' },
!isModifyMode && {
validator(_, value) {
const isExist = serviceNames.includes(value);
return isExist
? Promise.reject(new Error('重复的方法名,请换一个!'))
: Promise.resolve();
},
},
]}
extra={
!disabled && (
<Box>
<Button
type="link"
size="small"
onClick={() => {
const url = form.getFieldValue('url');
if (!url) {
return;
}
const funcName = url2serviceName(url);
form.setFieldValue('name', funcName);
}}
>
</Button>
</Box>
)
}
>
<Input placeholder="请输入数据服务调用名称" disabled={disabled || isModifyMode} />
</Form.Item>
<Form.Item label="路径" name="url" rules={[{ required: true, type: 'url' }]}>
<Input placeholder="请输入数据服务调用名称" disabled={disabled || isModifyMode} />
</Form.Item>
<Form.Item label="方法" name="method">
<Select
options={[
{ label: 'GET', value: 'get' },
{ label: 'POST', value: 'post' },
]}
placeholder="请选择 HTTP 请求方法"
disabled={disabled}
/>
</Form.Item>
<Form.Item
label="格式化响应"
name="formatter"
tooltip="提供格式化函数,对结果进行格式化"
validateTrigger="onBlur"
rules={[
{
validator(_, value) {
if (!value) {
return Promise.resolve();
}
return isValidFunctionCode(value)
? Promise.resolve()
: Promise.reject(new Error('请提供合法的函数代码!'));
},
},
]}
extra={
!disabled && (
<Box>
<Dropdown
menu={{
items: [
{ label: '直接返回(默认)', key: 'res => res' },
{ label: '返回数据部分(云音乐标准规范)', key: 'res => res.data' },
],
onClick: ({ key }) => {
form.setFieldValue('formatter', key);
},
}}
>
<Button type="link" size="small">
使
</Button>
</Dropdown>
</Box>
)
}
>
<InputCode showLineNumbers placeholder="res => res" editable={!disabled} />
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
{isModifyMode ? (
<Space>
{!disabled ? (
<>
<Button
onClick={() => {
setDisabled(true);
}}
>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</>
) : (
<Button
onClick={() => {
setDisabled(false);
}}
>
</Button>
)}
</Space>
) : (
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
)}
</Form.Item>
</Form>
);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Button, Form, Input, Space } from 'antd';
import { isValidExpressionCode } from '@music163/tango-core';
import { InputCode, Panel } from '@music163/tango-ui';
import { IVariableTreeNode } from '@music163/tango-helpers';
export interface AddStoreVariableFormProps {
parentNode: IVariableTreeNode;
onCancel?: React.MouseEventHandler<HTMLButtonElement>;
onSubmit?: (storeName: string, data: { name: string; initialValue: string }) => void;
}
export function AddStoreVariableForm({
parentNode,
onCancel,
onSubmit,
}: AddStoreVariableFormProps) {
return (
<Form
layout="vertical"
autoComplete="off"
validateTrigger="onBlur"
onFinish={(values) => {
if (onSubmit && parentNode) {
const [type, storeName] = parentNode.key.split('.');
onSubmit(storeName, values);
}
}}
>
<Form.Item label="所属模型">
<Input value={parentNode?.key} disabled />
</Form.Item>
<Form.Item
label="变量名"
name="name"
rules={[
{ required: true },
{ pattern: /^\w+$/, message: '非法的变量标识符' },
{
validator(_, value) {
const found = parentNode.children.find((item) => item.title === value);
return found
? Promise.reject(new Error('和已有变量名冲突,请换一个名字!'))
: Promise.resolve();
},
},
]}
>
<Input placeholder="请输入变量名" />
</Form.Item>
<Form.Item
label="初值"
name="initialValue"
rules={[
{ required: true },
{
validator(_, value) {
return value && isValidExpressionCode(value)
? Promise.resolve()
: Promise.reject(new Error('代码存在语法错误,请输入合法的代码片段!'));
},
},
]}
>
<InputCode shape="inset" placeholder="请输入初值" />
</Form.Item>
<Form.Item>
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
);
}
export interface AddStoreFormProps {
storeNames?: string[];
onCancel?: () => void;
onSubmit?: (data: { name: string }) => void;
}
export function AddStoreForm({ storeNames = [], onCancel, onSubmit }: AddStoreFormProps) {
return (
<Panel title="添加模型" subTitle="模型可以用来组织一组变量" bodyProps={{ p: 'l' }}>
<Form
autoComplete="off"
colon={false}
onFinish={(values) => {
onSubmit && onSubmit(values);
}}
>
<Form.Item
label="模型名称"
name="name"
rules={[
{ required: true },
{ pattern: /^[a-z]\w+$/, message: '非法的变量标识符' },
{
validator(_, value) {
const found = storeNames.includes(value);
return found
? Promise.reject(new Error('已存在该模型名,请换一个!'))
: Promise.resolve();
},
},
]}
>
<Input placeholder="请输入模型名称" />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Panel>
);
}

View File

@@ -0,0 +1,419 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
IVariableTreeNode,
filterTreeData,
isServiceVariablePath,
isStoreVariablePath,
noop,
parseServiceVariablePath,
} from '@music163/tango-helpers';
import { css, Box, Text } from 'coral-system';
import { Button, Popconfirm, Tooltip, Tree } from 'antd';
import {
CopyOutlined,
DeleteOutlined,
EyeOutlined,
FunctionOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { CopyClipboard, Panel, Search } from '@music163/tango-ui';
import { AddStoreForm, AddStoreVariableForm } from './add-store';
import { AddServiceForm } from './add-service';
import {
NodeCommonDetail,
ValueDefine,
ValueDefineProps,
ValueDetail,
ValueDetailProps,
} from './value-detail';
import { ValuePreview } from './value-preview';
import { ServicePreview } from './service-preview';
const varTreeStyle = css`
overflow: auto;
position: relative;
.ant-tree {
font-family: Consolas, Menlo, Courier, monospace;
}
.ant-tree-node-content-wrapper.ant-tree-node-content-wrapper-normal {
width: calc(100% - 50px);
}
.ant-tree .ant-tree-treenode {
padding: 0;
}
.ant-tree.ant-tree-directory .ant-tree-treenode::before {
bottom: 0;
}
.ant-tree-indent-unit {
width: 12px;
}
`;
type SelectNodeCallback = (data: IVariableTreeNode) => void;
type DetailModeType =
| 'detail'
| 'storeVariableDetail'
| 'serviceDetail'
| 'addVariable'
| 'addStore'
| 'addService';
interface IVariableTreeRenderDetailState {
mode: DetailModeType;
setMode: (mode: DetailModeType) => void;
activeNode: IVariableTreeNode;
clear: () => void;
}
export interface VariableTreeProps {
defaultValueDetailMode?: ValueDetailProps['defaultMode'];
dataSource: IVariableTreeNode[];
appContext?: object;
serviceModules?: any[];
getPreviewValue?: (node: IVariableTreeNode) => unknown;
getServiceData?: (serviceKey: string) => object;
getServiceNames?: (moduleName: string) => string[];
getStoreNames?: () => string[];
onSelect?: SelectNodeCallback;
onAddStoreVariable?: (storeName: string, data: any) => void;
onUpdateStoreVariable?: ValueDefineProps['onSave'];
onRemoveStoreVariable?: (variableKey: string) => void;
onAddStore?: (newStoreName: string) => void;
onAddService?: (data: object) => void;
onUpdateService?: (data: object) => void;
onRemoveService?: (variableKey: string) => void;
onCopy?: (data: IVariableTreeNode) => void;
onView?: SelectNodeCallback;
height?: number | string;
showViewButton?: boolean;
/**
* 自定义头部
*/
renderHeaderExtra?: (
props: VariableTreeProps,
state: IVariableTreeRenderDetailState,
) => React.ReactNode;
/**
* 自定义变量详情渲染
* @param props
* @param state
* @returns
*/
renderDetail?: (
props: VariableTreeProps,
state: IVariableTreeRenderDetailState,
) => React.ReactNode;
}
export function VariableTree(props: VariableTreeProps) {
const {
dataSource = [],
serviceModules = [],
appContext = {},
defaultValueDetailMode,
onSelect = noop,
onAddStoreVariable = noop,
onAddStore = noop,
onAddService = noop,
onUpdateStoreVariable = noop,
onUpdateService = noop,
onRemoveStoreVariable,
onRemoveService,
onCopy = noop,
onView = noop,
getServiceData,
getServiceNames,
getStoreNames,
getPreviewValue = noop,
showViewButton,
renderHeaderExtra,
renderDetail: renderDetailProp,
...rest
} = props;
const [keyword, setKeyword] = useState('');
const [activeNode, setActiveNode] = useState<IVariableTreeNode>();
const [mode, setMode] = useState<DetailModeType>();
const clear = useCallback(() => {
setActiveNode(null);
setMode(null);
}, []);
const selectNode = useCallback((node: IVariableTreeNode, callback?: SelectNodeCallback) => {
if (isStoreVariablePath(node.key)) {
setMode('storeVariableDetail');
} else if (isServiceVariablePath(node.key)) {
setMode('serviceDetail');
} else {
setMode('detail');
}
setActiveNode(node);
callback?.(node);
}, []);
const treeData = useMemo(() => {
const pattern = new RegExp(keyword, 'ig');
return keyword
? filterTreeData(dataSource, (leaf) => pattern.test(leaf.title), 'children', true)
: dataSource;
}, [keyword, dataSource]);
const state = { activeNode, mode, setMode, clear };
return (
<Box display="flex" columnGap="l" className="VariableTree" css={varTreeStyle} {...rest}>
<Box className="VariableList" width="40%">
{renderHeaderExtra?.(props, state)}
<Box mb="m" position="sticky" top="0" bg="white" zIndex={2}>
<Search placeholder="请输入变量名" onChange={(val) => setKeyword(val?.trim())} />
</Box>
<Tree
blockNode
showIcon={false}
defaultExpandAll
treeData={treeData}
onSelect={(keys, detail) => {
selectNode(detail.node, onSelect);
}}
titleRender={(node) => {
const isLeaf = !node.children;
if (isLeaf) {
const showView = node.showViewButton ?? showViewButton;
return (
<Box display="flex" justifyContent="space-between" alignItems="center">
<Text flex="1" truncated>
{node.title}
{node.type === 'function' && (
<Text color="text3" ml="m">
<FunctionOutlined />
</Text>
)}
</Text>
<Box flex="0 0 72px" textAlign="right">
{node.showRemoveButton && (
<Popconfirm
title={`确认删除吗 ${node.title}?该操作会导致引用此模型的代码报错,请谨慎操作!`}
onConfirm={() => {
if (isStoreVariablePath(node.key)) {
onRemoveStoreVariable(node.key);
} else {
onRemoveService(node.key);
}
}}
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
}}
/>
</Popconfirm>
)}
<CopyClipboard text={`tango.${node.key.replaceAll('.', '?.')}`}>
{({ copied, onClick }) => {
const label = copied ? '已复制' : '复制变量路径';
return (
<Tooltip title={label}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation();
onClick();
onCopy(node);
}}
/>
</Tooltip>
);
}}
</CopyClipboard>
{showView && (
<Tooltip title="查看变量详情">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
selectNode(node, onView);
}}
/>
</Tooltip>
)}
</Box>
</Box>
);
}
return (
<Box display="flex" alignItems="center" justifyContent="space-between">
<Text mr="m">{node.title}</Text>
<Box>
{/^\$?stores$/.test(node.key) && (
<Tooltip title="新建数据模型">
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation();
setMode('addStore');
}}
/>
</Tooltip>
)}
{node.showAddButton && /^stores\.[a-zA-Z0-9]+$/.test(node.key) && (
<Tooltip title={`${node.title} 中添加变量`}>
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation();
setActiveNode(node);
setMode('addVariable');
}}
/>
</Tooltip>
)}
{/^services(\.[a-zA-Z0-9]+)?$/.test(node.key) && (
<Tooltip title={`${node.title} 中添加服务函数`}>
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation();
setActiveNode(node);
setMode('addService');
}}
/>
</Tooltip>
)}
</Box>
</Box>
);
}}
/>
</Box>
<Box className="VariableDetail" flex="1" position="sticky" top="0" overflow="auto">
{(function renderDetail() {
const customRenderer = renderDetailProp?.(props, state);
if (customRenderer) {
return customRenderer;
}
if (mode === 'detail') {
return <NodeCommonDetail data={activeNode} />;
}
if (mode === 'storeVariableDetail') {
return (
<ValueDetail key={activeNode.key} defaultMode={defaultValueDetailMode}>
{(previewMode) =>
previewMode === 'runtime' ? (
<ValuePreview
value={getPreviewValue(activeNode)}
onCopy={(valuePath) => {
return ['tango', activeNode.key.replaceAll('.', '?.'), valuePath].join('.');
}}
/>
) : (
<ValueDefine data={activeNode} onSave={onUpdateStoreVariable} />
)
}
</ValueDetail>
);
}
if (mode === 'serviceDetail') {
return (
<>
<Panel shape="solid" title="服务函数配置">
<AddServiceForm
key={activeNode.key}
serviceModules={serviceModules}
serviceNames={(function () {
const { moduleName } = parseServiceVariablePath(activeNode.key);
return getServiceNames?.(moduleName) || [];
})()}
initialValues={{
...getServiceData?.(activeNode.key),
}}
onCancel={clear}
onSubmit={(values) => {
onUpdateService(values);
clear();
}}
/>
</Panel>
<Panel shape="solid" title="服务函数预览" mt="l">
<ServicePreview
key={activeNode.key}
appContext={appContext}
functionKey={activeNode.key}
/>
</Panel>
</>
);
}
if (mode === 'addVariable') {
return (
<AddStoreVariableForm
parentNode={activeNode}
onSubmit={(storeName, data) => {
onAddStoreVariable(storeName, data);
clear();
}}
onCancel={() => {
clear();
}}
/>
);
}
if (mode === 'addStore') {
return (
<AddStoreForm
storeNames={getStoreNames?.() || []}
onSubmit={({ name }) => {
onAddStore(name);
clear();
}}
onCancel={() => {
clear();
}}
/>
);
}
if (mode === 'addService') {
return (
<Panel shape="solid" title="创建服务函数">
<AddServiceForm
key={activeNode.key}
serviceModules={serviceModules}
serviceNames={activeNode.children?.map((item) => item.title) || []}
initialValues={{
moduleName: activeNode.title,
}}
onCancel={() => {
clear();
}}
onSubmit={(values) => {
onAddService(values);
clear();
}}
/>
</Panel>
);
}
})()}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Box } from 'coral-system';
import { Button, Empty } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { InputCode, Panel, JsonView } from '@music163/tango-ui';
import { isNil, logger, code2object, getValue } from '@music163/tango-helpers';
export interface ServicePreviewProps {
appContext?: any;
functionKey?: string;
}
export function ServicePreview({ appContext, functionKey }: ServicePreviewProps) {
const [payload, setPayload] = useState({});
const [result, setResult] = useState<any>();
const [error, setError] = useState('');
return (
<Box>
<Panel title="请求参数" bodyProps={{ px: 'l' }}>
<InputCode
placeholder={'添加请求参数,对象格式,如 { key: value }'}
editable
showLineNumbers
onChange={(value: string) => {
const obj = code2object(value);
setPayload(obj);
}}
/>
<Button
block
type="primary"
style={{ margin: '8px 0' }}
disabled={!appContext}
onClick={() => {
if (!appContext) {
setError('执行上下文未准备好,请关闭面板重试');
return;
}
try {
const fn = getValue(appContext, functionKey);
fn(payload).then((data: any) => {
setError('');
setResult(data);
});
} catch (err) {
setError('接口调用失败,请检查参数是否正确');
logger.error(err);
}
}}
icon={<PlayCircleOutlined />}
>
</Button>
</Panel>
<Panel title="请求响应" bodyProps={{ px: 'l' }}>
{error || (result ? <ResponseDataPreview data={result} /> : '点击预览按钮测试接口返回值')}
</Panel>
</Box>
);
}
interface ResponseDataPreviewProps {
data?: object;
}
function ResponseDataPreview({ data }: ResponseDataPreviewProps) {
let ret: React.ReactNode;
if (!isNil(data)) {
switch (typeof data) {
case 'object':
ret = <JsonView src={data as object} />;
break;
default:
ret = String(data);
break;
}
}
const initialRet = <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />;
return (
<Box
className="ResponseDataPreview"
overflow="auto"
height="auto"
minHeight={260}
marginTop={10}
>
{ret || initialRet}
</Box>
);
}

View File

@@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { IVariableTreeNode, isNil, noop, useBoolean } from '@music163/tango-helpers';
import { Empty, Space, Button, Radio, Alert } from 'antd';
import { Box } from 'coral-system';
import { InputCode, Panel } from '@music163/tango-ui';
import { isValidExpressionCode } from '@music163/tango-core';
const previewOptions = [
{ label: '运行时', value: 'runtime' },
{ label: '定义', value: 'define' },
];
export type ValueDetailModeType = 'runtime' | 'define';
export interface ValueDetailProps {
defaultMode?: ValueDetailModeType;
children: (mode: ValueDetailModeType) => React.ReactNode;
}
export function ValueDetail({ defaultMode = 'runtime', children }: ValueDetailProps) {
const [mode, setMode] = useState<ValueDetailModeType>(defaultMode);
return (
<Panel
title="变量详情"
shape="solid"
extra={
<Radio.Group
optionType="button"
buttonStyle="solid"
size="small"
value={mode}
onChange={(e) => {
setMode(e.target.value);
}}
options={previewOptions}
/>
}
>
{children(mode)}
</Panel>
);
}
export interface ValueDefineProps {
data: IVariableTreeNode;
onSave?: (variableKey: string, code: string) => void;
}
/**
* 变量值定义面板
*/
export function ValueDefine({ data, onSave = noop }: ValueDefineProps) {
const [value, setValue] = useState(data.raw);
const [error, setError] = useState('');
const [editable, { on, off }] = useBoolean();
if (isNil(data.raw)) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="缺失定义数据" />;
}
return (
<Box>
<InputCode
shape="inset"
value={value}
onChange={(nextValue) => setValue(nextValue)}
readOnly={!editable}
onBlur={() => {
if (!value) {
setError('输入内容不可为空!');
} else if (value && !isValidExpressionCode(`foo = ${value}`)) {
// 这里先保证输入是一个合法的表达式
setError('代码格式错误,请检查代码语法!');
} else {
setError('');
}
}}
/>
<Box color="red" my="m">
{error}
</Box>
<Box mt="l">
{editable ? (
<Space>
<Button
onClick={() => {
off();
setError('');
}}
>
</Button>
<Button
type="primary"
onClick={() => {
if (error) {
return;
}
onSave(data.key, value);
off();
}}
>
</Button>
</Space>
) : (
<Button onClick={on}></Button>
)}
</Box>
</Box>
);
}
export interface NodeCommonDetailProps {
data: IVariableTreeNode;
}
export function NodeCommonDetail({ data }: NodeCommonDetailProps) {
if (!data?.help) {
return <div />;
}
return <Alert type="info" message={`使用说明:${data.help}`} closable />;
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { isFunction, isNil, isString } from '@music163/tango-helpers';
import { InputCode, JsonView, JsonViewProps } from '@music163/tango-ui';
import { Empty } from 'antd';
interface ValuePreviewProps {
value?: unknown;
/**
* 选择预览结点的回调
*/
onCopy?: JsonViewProps['onCopy'];
}
export function ValuePreview({ value, onCopy }: ValuePreviewProps) {
if (isNil(value)) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂时没有可预览的数据" />;
}
if (isFunction(value)) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂不支持预览函数" />;
}
if (typeof value === 'object') {
return <JsonView src={value} enableCopy onCopy={onCopy} />;
}
return (
<InputCode
shape="inset"
value={isString(value) ? `"${value}"` : String(value)}
editable={false}
/>
);
}

View File

@@ -1 +1,2 @@
export * from './dom';
export * from './template';

View File

@@ -0,0 +1,9 @@
const newStoreTemplate = `
import { defineStore } from '@music163/tango-boot';
export default defineStore({
});
`;
export const CODE_TEMPLATES = {
newStoreTemplate,
};

View File

@@ -13,6 +13,7 @@ export * from './toolbar';
export * from './selection-menu';
export * from './widgets';
export * from './themes';
export * from './components';
export { register as registerSetter } from '@music163/tango-setting-form';

View File

@@ -6,13 +6,21 @@ import {
isWrappedByExpressionContainer,
value2expressionCode,
} from '@music163/tango-core';
import { getVariableContent, noop, useBoolean, getValue } from '@music163/tango-helpers';
import {
getVariableContent,
noop,
useBoolean,
getValue,
IVariableTreeNode,
} from '@music163/tango-helpers';
import { CloseCircleFilled, ExpandAltOutlined } from '@ant-design/icons';
import { IconButton, Panel, InputCode } from '@music163/tango-ui';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { useWorkspace, useWorkspaceData } from '@music163/tango-context';
import { EditableVariableTree, IVariableTreeNode } from '../components';
import { VariableTree } from '../components';
import { useSandboxQuery } from '../context';
import { CODE_TEMPLATES } from '../helpers';
import { shapeServiceValues } from '../sidebar/datasource-panel/interface-config';
export const expressionValueValidate = (value: string) => {
if (isWrappedByExpressionContainer(value)) {
@@ -77,6 +85,7 @@ export function ExpressionSetter(props: ExpressionSetterProps) {
value: valueProp,
status,
allowClear = true,
newStoreTemplate,
} = props;
const [visible, { on, off }] = useBoolean();
const [inputValue, setInputValue] = useState(() => {
@@ -143,6 +152,7 @@ export function ExpressionSetter(props: ExpressionSetterProps) {
subTitle={modalTip}
placeholder={placeholder}
autoCompleteOptions={autoCompleteOptions}
newStoreTemplate={newStoreTemplate}
visible={visible}
value={inputValue}
onCancel={() => off()}
@@ -166,6 +176,10 @@ export interface ExpressionModalProps {
onOk?: (value: string) => void;
dataSource?: IVariableTreeNode[];
autoCompleteOptions?: string[];
/**
* 新建 store 的模板代码
*/
newStoreTemplate?: string;
}
export function ExpressionModal({
@@ -179,18 +193,19 @@ export function ExpressionModal({
value,
dataSource,
autoCompleteOptions,
newStoreTemplate = CODE_TEMPLATES.newStoreTemplate,
}: ExpressionModalProps) {
const [exp, setExp] = useState(value ?? defaultValue);
const [error, setError] = useState('');
const workspace = useWorkspace();
const onAction = useCallback(
(action: string, args: unknown[]) => {
workspace[action]?.(...args);
},
[workspace],
);
const sandbox = useSandboxQuery();
const { expressionVariables } = useWorkspaceData();
const serviceModules = Object.keys(workspace.serviceModules).map((key) => ({
label: key === 'index' ? '默认模块' : key,
value: key,
}));
const sandbox = useSandboxQuery();
const evaluateContext = sandbox.window;
const handleExpInputChange = (val: string) => {
@@ -231,10 +246,29 @@ export function ExpressionModal({
/>
{error ? <Text color="red"></Text> : null}
</Panel>
<Panel title="从变量列表中选中" shape="solid" borderTop="0" overflow="hidden">
<EditableVariableTree
<Panel
title="从变量列表中选中"
shape="solid"
borderTop="0"
overflow="hidden"
bodyProps={{ overflow: 'hidden' }}
>
<VariableTree
height={380}
showViewButton
dataSource={dataSource || expressionVariables}
appContext={sandbox?.window['tango']}
getStoreNames={() => Object.keys(workspace.storeModules)}
serviceModules={serviceModules}
getServiceData={(serviceKey) => {
const data = workspace.getServiceFunction(serviceKey);
return {
name: data.name,
moduleName: data.moduleName,
method: 'get',
...data.config,
};
}}
onSelect={(node) => {
if (!node.key) {
return;
@@ -250,17 +284,28 @@ export function ExpressionModal({
}
setExp(str);
}}
onAddVariable={(storeName, data) => {
onAction('addStoreState', [storeName, data.name, data.initialValue]);
onAddStoreVariable={(storeName, data) => {
workspace.addStoreState(storeName, data.name, data.initialValue);
}}
onDeleteVariable={(storeName, stateName) => {
onAction('removeStoreState', [storeName, stateName]);
onUpdateStoreVariable={(variableKey, code) => {
workspace.updateStoreVariable(variableKey, code);
}}
onDeleteStore={(storeName) => {
onAction('removeStoreModule', [storeName]);
onAddStore={(storeName) => {
workspace.addStoreFile(storeName, newStoreTemplate);
}}
onSave={(code, node) => {
onAction('updateModuleCodeByVariablePath', [node.key, code]);
onRemoveStoreVariable={(variableKey) => {
workspace.removeStoreVariable(variableKey);
}}
onRemoveService={(serviceKey) => {
workspace.removeServiceFunction(serviceKey);
}}
onAddService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.addServiceFunction(name, payload, moduleName);
}}
onUpdateService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.updateServiceFunction(name, payload, moduleName);
}}
getPreviewValue={(node) => {
if (!node || !node.key) {

View File

@@ -5,8 +5,9 @@ import { getValue, isFunction } from '@music163/tango-helpers';
import { FormItemComponentProps } from '@music163/tango-setting-form';
import { MenuOutlined } from '@ant-design/icons';
import { useWorkspace, useWorkspaceData } from '@music163/tango-context';
import { EditableVariableTreeModal } from '../components';
import { VariableTreeModal } from '../components';
import { useSandboxQuery } from '../context';
import { CODE_TEMPLATES } from '../helpers';
function object2treeData(
val: any,
@@ -52,43 +53,40 @@ function traverseTreeData(val: any, callback: (val: any) => void) {
}
}
export function ModelSetter({ value, onChange }: FormItemComponentProps) {
export function ModelSetter({
value,
onChange,
newStoreTemplate = CODE_TEMPLATES.newStoreTemplate,
}: FormItemComponentProps) {
const [inputValue, setInputValue] = useState(value);
const { modelVariables } = useWorkspaceData();
const evaluateContext = useSandboxQuery().window || {};
const workspace = useWorkspace();
const onAction = useCallback(
(action: string, args: unknown[]) => {
workspace[action]?.(...args);
},
[workspace],
);
const definedVariables = useMemo(() => {
const map = new Map();
const list: string[] = [];
traverseTreeData(modelVariables, (node) => {
if (node.key.split('.').length > 1) {
map.set(node.key, node);
list.push(node.key);
}
});
return map;
return list;
}, [modelVariables]);
const variables = evaluateContext['tango']?.stores
? [
object2treeData(evaluateContext['tango']?.stores, 'stores', 0, 1, (keyPath, val) => {
if (keyPath.split('.').length === 2 && definedVariables.has(keyPath)) {
return {
showAddChildIcon: true,
showRemoveIcon: true,
};
}
const ret: any = {};
if (isFunction(val)) {
// 不可以同步给函数类型变量
return {
disabled: true,
type: 'function',
};
ret.disabled = true;
ret.type = 'function';
}
if (!definedVariables.includes(keyPath)) {
ret.showAddButton = false;
ret.showRemoveButton = false;
}
return ret;
}),
]
: modelVariables;
@@ -122,31 +120,34 @@ export function ModelSetter({ value, onChange }: FormItemComponentProps) {
onChange={onInputChange}
onBlur={onInputBlur}
suffix={
<EditableVariableTreeModal
<VariableTreeModal
title="同步到的变量"
trigger={
<Tooltip title="从模型列表选择" placement="topRight">
<MenuOutlined />
</Tooltip>
}
modes={['preview']}
dataSource={variables as any[]}
onSelect={(node) => {
const modelPath = node.key.split('.').slice(1).join('.');
onChange(modelPath);
}}
onAddVariable={(storeName, data) => {
onAction('addStoreState', [storeName, data.name, data.initialValue]);
onAddStoreVariable={(storeName, data) => {
workspace.addStoreState(storeName, data.name, data.initialValue);
}}
onDeleteVariable={(storeName, stateName) => {
onAction('removeStoreState', [storeName, stateName]);
onUpdateStoreVariable={(variableKey, code) => {
workspace.updateStoreVariable(variableKey, code);
}}
onDeleteStore={(storeName) => {
onAction('removeStoreModule', [storeName]);
onAddStore={(storeName) => {
workspace.addStoreFile(storeName, newStoreTemplate);
}}
onSave={(code, node) => {
onAction('updateModuleCodeByVariablePath', [node.key, code]);
onRemoveStoreVariable={(variablePath) => {
workspace.removeStoreVariable(variablePath);
}}
onRemoveService={(serviceKey) => {
workspace.removeServiceFunction(serviceKey);
}}
getStoreNames={() => Object.keys(workspace.storeModules)}
getPreviewValue={(node) => {
if (!node || !node.key) {
return;

View File

@@ -4,8 +4,6 @@ import { Tabs } from '@music163/tango-ui';
import InterfaceConfig from './interface-config';
import ProxyConfig from './proxy-config';
export * from './interface-config';
export function DataSourcePanel(props: Record<string, any>) {
return (
<Box className="DataSourceView" height="100%" overflowY="auto">

View File

@@ -1,492 +1,60 @@
import React, { useState, useEffect, useMemo } from 'react';
import React from 'react';
import { observer, useWorkspace, useWorkspaceData } from '@music163/tango-context';
import { Box, css } from 'coral-system';
import { Button, FormProps, Empty, Space, Dropdown, Form, Select, Input } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { InputCode, Panel, JsonView, Search } from '@music163/tango-ui';
import {
isNil,
getVariableContent,
isValidFunctionCode,
logger,
code2object,
filterTreeData,
} from '@music163/tango-helpers';
import { Box } from 'coral-system';
import { getVariableContent } from '@music163/tango-helpers';
import { isWrappedByExpressionContainer } from '@music163/tango-core';
import { useSandboxQuery } from '../../context';
import { VariableTree } from '../../components';
import { useSandboxQuery } from '../../context';
/*
* 服务函数的操作类型
*/
enum ServiceFunctionOperationModeType {
ADD = 'add',
UPDATE = 'update',
DELETE = 'delete',
}
/**
* 服务函数 HTTP Type
* 云音乐网关只支持 get 和 post
*/
enum ServiceFunctionMethodType {
GET = 'GET',
// PUT = 'PUT',
POST = 'POST',
// PATCH = 'PATCH',
// DELETE = 'DELETE',
}
/**
* 将 api 路径转换为默认的驼峰方法名
*/
export const getApiDefaultName = (url: string) =>
url
// 去除 api + 模块名前缀
// - 云音乐 api 规范为 /api/模块名/
// - 后端公技基本使用 /模块名/api/
// - 中台类服务似乎常用 /api/middle/模块名/
// TODO: 是否需要去除模块名?一般同一个应用是同一个模块名,去掉可以缩减方法名长度,但确实有模块名不一样且后面的路径完全一致的接口
// 目前的实现是去除了模块名,只干掉 /api/middle/ 和 /api/backend/ 这种常用前缀
.replace(/^\/[^/]+?\/api\/|^\/api\/middle\/|^\/api\/backend\/|^\/api\//, '')
// 去除路由参数
.replace(/\/\{.*?\}/, '')
// 忽略下划线与减号,将后面的字符转成大驼峰
.replace(/[-/_]+\w/g, (str) => str.replace(/[-/_]+/, '').toUpperCase())
// 首字母转小写
.replace(/^./, (str) => str.toLowerCase())
// 方法名以数字开头,添加 api 前缀
.replace(/^\d/, (str) => `api${str}`);
interface DataServiceViewProps {
onAdd?: (values: Record<string, string> | Array<Record<string, string>>) => void;
onUpdate?: (values: Record<string, string>) => void;
onDelete?: (values: Record<string, string>) => void;
}
// http 方法类型
const httpMethods = Object.keys(ServiceFunctionMethodType).map((key) => ({
label: key,
value: key,
}));
const detailFormStyle = css`
.ant-form-item {
margin-bottom: 12px;
// 移除掉不必要的属性
export function shapeServiceValues(val: any) {
const shapeValues = { ...val };
delete shapeValues.type;
// 兼容旧版,如果 formatter 包裹了 {} 则删掉首尾
if (shapeValues.formatter && isWrappedByExpressionContainer(shapeValues.formatter)) {
shapeValues.formatter = getVariableContent(shapeValues.formatter);
}
`;
return shapeValues;
}
const DataSourceView = observer(({ onAdd, onUpdate, onDelete }: DataServiceViewProps) => {
const [serviceData, setServiceData] = useState<any>();
const [keyword, setKeyword] = useState<string>();
const workspace = useWorkspace();
const DataSourceView = observer(() => {
const sandbox = useSandboxQuery();
const workspace = useWorkspace();
const { serviceVariables } = useWorkspaceData();
const serviceModules = Object.keys(workspace.serviceModules).map((key) => ({
label: key === 'index' ? '默认模块' : key,
value: key,
}));
const serviceFunctions = serviceVariables.reduce((acc, cur) => {
acc = acc.concat(cur.children.map((item: any) => item.key));
return acc;
}, []);
const dataSource = useMemo(() => {
if (!keyword) {
return serviceVariables;
}
return filterTreeData(
serviceVariables,
(leaf) => leaf.title.includes(keyword),
'children',
true,
);
}, [serviceVariables, keyword]);
const isAddMode = serviceData && !serviceData.name;
return (
<Box className="ServiceFunctionList" display="flex" borderTopColor="line.normal">
<Box width="35%" overflow="auto" borderRight="solid" borderColor="line.normal">
<Panel
height="100%"
bodyProps={{
flex: 1,
}}
>
<Box
p="m"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
rowGap="m"
>
<Button
block
onClick={() => {
setServiceData({});
}}
>
</Button>
<Search placeholder="搜索服务函数" onChange={setKeyword} />
</Box>
<VariableTree
dataSource={dataSource}
showDeleteIcon
onSelect={(item) => {
setServiceData({ key: item.key, ...workspace.getServiceFunction(item.key) });
}}
onRemove={(item) => {
workspace.removeServiceFunction(item.key);
onDelete?.(item);
}}
/>
</Panel>
</Box>
<Box width="65%" overflow="auto" css={detailFormStyle}>
{serviceData && (
<>
<Panel title={isAddMode ? '新建服务函数' : '服务函数详情'}>
<ServiceDetailForm
key={serviceData.key || 'createSF'}
serviceModules={serviceModules}
serviceKeys={serviceFunctions}
initialValues={
isAddMode
? {
moduleName: 'index',
}
: {
name: serviceData.name,
moduleName: serviceData.moduleName,
method: 'get',
...serviceData.config,
}
}
onCancel={() => {
setServiceData(undefined);
}}
onSubmit={(values, mode: ServiceFunctionOperationModeType) => {
function shapeServiceValues(val: any) {
const shapeValues = { ...val };
delete shapeValues.type;
// 兼容旧版,如果 formatter 包裹了 {} 则删掉首尾
if (
shapeValues.formatter &&
isWrappedByExpressionContainer(shapeValues.formatter)
) {
shapeValues.formatter = getVariableContent(shapeValues.formatter);
}
return shapeValues;
}
// 移除掉不必要的属性
const { moduleName, ...data } = shapeServiceValues(values);
if (mode === ServiceFunctionOperationModeType.ADD) {
workspace.addServiceFunction(data, moduleName);
onAdd && onAdd(values);
} else if (mode === ServiceFunctionOperationModeType.UPDATE) {
workspace.updateServiceFunction(data, moduleName);
onUpdate && onUpdate(values);
}
}}
/>
</Panel>
{serviceData?.key ? (
<ServiceFunctionPreview
key={serviceData.key}
appContext={sandbox?.window['tango']}
functionName={serviceData.key}
/>
) : null}
</>
)}
{!serviceData && (
<Box py="xxl">
<Empty description="请从左侧选择数据服务函数或新建数据服务函数" />
</Box>
)}
</Box>
<Box className="ServiceFunctionList" p="m">
<VariableTree
dataSource={serviceVariables}
appContext={sandbox?.window['tango']}
serviceModules={serviceModules}
onRemoveService={(variableKey) => {
workspace.removeServiceFunction(variableKey);
}}
onAddService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.addServiceFunction(name, payload, moduleName);
}}
onUpdateService={(data) => {
const { name, moduleName, ...payload } = shapeServiceValues(data);
workspace.updateServiceFunction(name, payload, moduleName);
}}
getServiceData={(serviceKey) => {
const data = workspace.getServiceFunction(serviceKey);
return {
name: data.name,
moduleName: data.moduleName,
method: 'get',
...data.config,
};
}}
/>
</Box>
);
});
interface ServiceDetailFormProps extends FormProps {
serviceKeys?: string[];
serviceModules?: any[];
onCancel?: () => void;
onSubmit?: (values: any, mode: ServiceFunctionOperationModeType) => void;
}
function ServiceDetailForm({
serviceKeys = [],
serviceModules = [],
onCancel,
onSubmit,
initialValues,
...formProps
}: ServiceDetailFormProps) {
const isModifyMode = !!initialValues?.name; // 是否为更新模式
const [disabled, setDisabled] = useState(isModifyMode);
const [form] = Form.useForm();
// 监听服务类型变化,默认是自定义
const formType = Form.useWatch('type', form);
// 新建的时候,类型变换重置部分字段
useEffect(() => {
if (!formType) return;
!isModifyMode &&
form.setFieldsValue({
url: undefined,
method: undefined,
formatter: undefined,
});
}, [formType, form, isModifyMode]);
return (
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 17 }}
colon={false}
layout="horizontal"
initialValues={initialValues}
onFinish={(values) => {
onSubmit(
values,
isModifyMode
? ServiceFunctionOperationModeType.UPDATE
: ServiceFunctionOperationModeType.ADD,
);
setDisabled(true);
}}
{...formProps}
>
<Form.Item
label="命名空间"
name="moduleName"
rules={[
{
required: true,
},
]}
>
<Select options={serviceModules} disabled={disabled || isModifyMode} />
</Form.Item>
<Form.Item
label="方法名"
name="name"
rules={[
{ required: true },
{ pattern: /^[a-z]\w+$/, message: '请输入合法的方法名称' },
!isModifyMode && {
validator(_, value) {
const isExist = serviceKeys.includes(
['services', form.getFieldValue('moduleName'), value].join('.'),
);
return isExist
? Promise.reject(new Error('重复的方法名,请换一个!'))
: Promise.resolve();
},
},
]}
extra={
!disabled &&
!isModifyMode && (
<Box>
<Button
type="link"
size="small"
disabled={!form.getFieldValue('url')}
onClick={() => {
const funcName = getApiDefaultName(form.getFieldValue('url'));
form.setFieldValue('name', funcName);
}}
>
使
</Button>
</Box>
)
}
>
<Input placeholder="请输入数据服务调用名称" disabled={disabled || isModifyMode} />
</Form.Item>
<Form.Item label="路径" name="url" rules={[{ required: true, type: 'url' }]}>
<Input placeholder="请输入数据服务调用名称" disabled={disabled || isModifyMode} />
</Form.Item>
<Form.Item label="方法" name="method">
<Select options={httpMethods} placeholder="请选择 HTTP 请求方法" disabled={disabled} />
</Form.Item>
<Form.Item
label="格式化响应"
name="formatter"
tooltip="提供格式化函数,对结果进行格式化"
validateTrigger="onBlur"
rules={[
{
validator(_, value) {
if (!value) {
return Promise.resolve();
}
return isValidFunctionCode(value)
? Promise.resolve()
: Promise.reject(new Error('请提供合法的函数代码!'));
},
},
]}
extra={
!disabled && (
<Box>
<Dropdown
menu={{
items: [
{ label: '直接返回(默认)', key: 'res => res' },
{ label: '返回数据部分(云音乐标准规范)', key: 'res => res.data' },
],
onClick: ({ key }) => {
form.setFieldValue('formatter', key);
},
}}
>
<Button type="link" size="small">
使
</Button>
</Dropdown>
</Box>
)
}
>
<InputCode editable={!disabled} showLineNumbers placeholder="res => res" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
{isModifyMode ? (
<Space>
{!disabled ? (
<>
<Button
onClick={() => {
setDisabled(true);
}}
>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</>
) : (
<Button
onClick={() => {
setDisabled(false);
}}
>
</Button>
)}
</Space>
) : (
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
)}
</Form.Item>
</Form>
);
}
interface ServicePreviewProps {
data?: object;
}
function ServicePreview({ data }: ServicePreviewProps) {
let ret: React.ReactNode;
if (!isNil(data)) {
switch (typeof data) {
case 'object':
ret = <JsonView src={data as object} />;
break;
default:
ret = String(data);
break;
}
}
const initialRet = <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />;
return (
<Box
className="ServicePreviewPanel"
overflow="auto"
height="auto"
minHeight={260}
marginTop={10}
>
{ret || initialRet}
</Box>
);
}
interface ServiceFunctionPreviewProps {
appContext?: any;
functionName?: string;
}
function ServiceFunctionPreview({ appContext, functionName }: ServiceFunctionPreviewProps) {
const [payload, setPayload] = useState({});
const [result, setResult] = useState<any>();
const [error, setError] = useState('');
return (
<Panel
title="接口测试"
extra={
<Space>
<Button
disabled={!appContext}
size="small"
onClick={() => {
if (!appContext) {
setError('执行上下文未准备好,请关闭面板重试');
return;
}
try {
appContext.services[functionName](payload).then((data: any) => {
setResult(data);
});
} catch (err) {
setError('接口调用失败,请检查参数是否正确');
logger.error(err);
}
}}
icon={<PlayCircleOutlined />}
>
</Button>
</Space>
}
borderTop="solid"
borderTopColor="line.normal"
>
<Panel title="请求参数" bodyProps={{ px: 'l' }}>
<InputCode
placeholder={'添加请求参数,对象格式,如 { key: value }'}
editable
showLineNumbers
onChange={(value: string) => {
const obj = code2object(value);
setPayload(obj);
}}
/>
</Panel>
<Panel title="请求响应" bodyProps={{ px: 'l' }}>
{error || (result ? <ServicePreview data={result} /> : '点击预览按钮测试接口返回值')}
</Panel>
</Panel>
);
}
export default DataSourceView;

View File

@@ -1,55 +1,39 @@
import React from 'react';
import { Button, Space, Form, Input } from 'antd';
import { FileAddOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { FileAddOutlined } from '@ant-design/icons';
import { useBoolean } from '@music163/tango-helpers';
import { Panel, IconButton } from '@music163/tango-ui';
import { Panel } from '@music163/tango-ui';
import { observer, useWorkspace, useWorkspaceData } from '@music163/tango-context';
import { EditableVariableTree, EditableVariableTreeProps } from '../components';
import { VariableTree } from '../components';
import { CODE_TEMPLATES } from '../helpers';
import { AddStoreForm } from '../components/variable-tree/add-store';
const defaultStoreTemplate = `
import { defineStore } from '@music163/tango-boot';
export default defineStore({
});
`;
export interface VariablePanelProps extends EditableVariableTreeProps {
wrapperHeight?: number | string;
export interface VariablePanelProps {
newStoreTemplate?: string;
}
export const VariablePanel = observer(
({
wrapperHeight = '100%',
newStoreTemplate = defaultStoreTemplate,
...restProps
}: VariablePanelProps) => {
({ newStoreTemplate = CODE_TEMPLATES.newStoreTemplate }: VariablePanelProps) => {
const [isAdd, { on, off }] = useBoolean();
const workspace = useWorkspace();
const { storeVariables } = useWorkspaceData();
const storeNames = storeVariables.map((item) => item.title);
const storeNames = storeVariables.map((item) => item.title) as string[];
return (
<Panel
className="ModelView"
height={wrapperHeight}
height="100%"
title="视图模型与变量管理"
subTitle={
<IconButton
tooltip="如何使用"
icon={<QuestionCircleOutlined />}
href="https://music-doc.st.netease.com/st/tango-docs/docs/guide/basic/model"
/>
}
extra={
<Button size="small" type="text" icon={<FileAddOutlined />} onClick={on}>
<Button size="small" type="primary" icon={<FileAddOutlined />} onClick={on}>
</Button>
}
bodyProps={{ p: 0 }}
bodyProps={{ p: 'm' }}
>
{isAdd ? (
<AddStoreForm
existNames={storeNames}
storeNames={storeNames}
onCancel={off}
onSubmit={(values) => {
workspace.addStoreFile(values.name, newStoreTemplate);
@@ -57,74 +41,21 @@ export const VariablePanel = observer(
}}
/>
) : (
<EditableVariableTree
modes={['add', 'define']}
defaultMode="define"
<VariableTree
defaultValueDetailMode="define"
dataSource={storeVariables}
onAddVariable={(storeName, data) => {
onAddStoreVariable={(storeName, data) => {
workspace.addStoreState(storeName, data.name, data.initialValue);
}}
onDeleteVariable={(storeName, stateName) => {
workspace.removeStoreState(storeName, stateName);
onRemoveStoreVariable={(variableKey) => {
workspace.removeStoreVariable(variableKey);
}}
onDeleteStore={(storeName) => {
workspace.removeStoreModule(storeName);
onUpdateStoreVariable={(variableKey, code) => {
workspace.updateStoreVariable(variableKey, code);
}}
onSave={(code, node) => {
workspace.updateModuleCodeByVariablePath(node.key, code);
}}
height="100%"
{...restProps}
/>
)}
</Panel>
);
},
);
interface AddStoreFormProps {
existNames?: string[];
onCancel?: () => void;
onSubmit?: (data: { name: string }) => void;
}
function AddStoreForm({ existNames = [], onCancel, onSubmit }: AddStoreFormProps) {
return (
<Panel title="添加模型" subTitle="模型可以用来组织一组变量" bodyProps={{ p: 'l' }}>
<Form
autoComplete="off"
colon={false}
onFinish={(values) => {
onSubmit && onSubmit(values);
}}
>
<Form.Item
label="模型名称"
name="name"
rules={[
{ required: true },
{ pattern: /^[a-z]\w+$/, message: '非法的变量标识符' },
{
validator(_, value) {
const found = existNames.includes(value);
return found
? Promise.reject(new Error('已存在该模型名,请换一个!'))
: Promise.resolve();
},
},
]}
>
<Input placeholder="请输入模型名称" />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Panel>
);
}

View File

@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.0.0-alpha.2](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0-alpha.1...@music163/tango-helpers@1.0.0-alpha.2) (2024-01-16)
### Bug Fixes
- 优化变量树实现 ([#90](https://github.com/netease/tango/issues/90)) ([62d403f](https://github.com/netease/tango/commit/62d403f80a5ad08c216bc0c035ffc12c2cf329d2))
# [1.0.0-alpha.1](https://github.com/netease/tango/compare/@music163/tango-helpers@1.0.0-alpha.0...@music163/tango-helpers@1.0.0-alpha.1) (2023-12-29)
### Bug Fixes
- refactor VariableTree & Workspace ([#83](https://github.com/netease/tango/issues/83)) ([8c07821](https://github.com/netease/tango/commit/8c07821d93cea4dfc43f81ca948b845176821184))
## [0.1.8](https://github.com/netease/tango/compare/@music163/tango-helpers@0.1.7...@music163/tango-helpers@0.1.8) (2023-11-23)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-helpers",
"version": "1.0.0-alpha.0",
"version": "1.0.0-alpha.2",
"description": "Shared types, helpers, and hooks of tango-apps",
"keywords": [
"shared",

View File

@@ -43,3 +43,21 @@ export function isPromise(obj: any) {
export function isNil(val: any) {
return val == null;
}
/**
* 是否是状态变量的 path
* @param key
* @returns
*/
export function isStoreVariablePath(key: string) {
return /^stores\.[a-zA-Z0-9]+\.\w+$/.test(key);
}
/**
* 是否是服务变量的 path
* @param key
* @returns
*/
export function isServiceVariablePath(key: string) {
return /^services\.[a-zA-Z0-9]+/.test(key);
}

View File

@@ -301,3 +301,84 @@ export function getCodeBlockFormMarkdown(markdown: string) {
return match[2];
}
}
export function url2serviceName(url: string) {
if (url.startsWith('http')) {
// 去除域名前缀
url = url
.replace(/https?:\/\//, '')
.split('/')
.slice(1)
.join('/');
}
return (
url
// 去除 api + 模块名前缀
// - 云音乐 api 规范为 /api/模块名/
// - 后端公技基本使用 /模块名/api/
// - 中台类服务似乎常用 /api/middle/模块名/
// 目前的实现是去除了模块名,只干掉 /api/middle/ 和 /api/backend/ 这种常用前缀
.replace(/^\/[^/]+?\/api\/|^\/api\/middle\/|^\/api\/backend\/|^\/api\//, '')
// 去除路由参数
.replace(/\/\{.*?\}/, '')
// 忽略下划线与减号,将后面的字符转成大驼峰
.replace(/[-/_]+\w/g, (str) => str.replace(/[-/_]+/, '').toUpperCase())
// 首字母转小写
.replace(/^./, (str) => str.toLowerCase())
// 方法名以数字开头,添加 api 前缀
.replace(/^\d/, (str) => `api${str}`)
);
}
/**
* 解析状态变量的 path
* @example stores.foo.bar => { storeName: 'foo', variableName: 'bar' }
* @example stores.user.count => { storeName: 'user', variableName: 'count' }
*
* @param variablePath
* @returns
*/
export function parseStoreVariablePath(variablePath: string) {
const [, storeName, variableName] = variablePath.split('.');
return {
storeName,
variableName,
};
}
/**
* 解析服务变量的 path
* @param variablePath
* @returns
*
* @example services.list => { moduleName: 'index', name: 'list' }
* @example services.sub.list => { moduleName: 'sub', name: 'list' }
* @example foo => undefined
*/
export function parseServiceVariablePath(variablePath: string) {
const parts = variablePath.split('.');
if (parts[0] !== 'services') {
return {};
}
let moduleName = 'index';
let name = '';
switch (parts.length) {
case 2: {
name = parts[1];
break;
}
case 3: {
moduleName = parts[1];
name = parts[2];
break;
}
default:
break;
}
return {
moduleName,
name,
};
}

View File

@@ -33,6 +33,56 @@ export type OptionType = {
relatedImports?: string[];
};
/**
* 变量树节点类型
*/
export interface IVariableTreeNode {
/**
* 唯一标识符
*/
key: string;
/**
* 标题
*/
title?: string;
/**
* 辅助提示信息
*/
help?: string;
/**
* 是否可选中
*/
selectable?: boolean;
/**
* 展示删除按钮
*/
showRemoveButton?: boolean;
/**
* 展示添加按钮
*/
showAddButton?: boolean;
/**
* 展示查看按钮
*/
showViewButton?: boolean;
/**
* 结点类型,用来展示图标
*/
type?: 'function' | 'property';
/**
* 定义的原始值
*/
raw?: any;
/**
* 是否禁用
*/
disabled?: boolean;
/**
* 子结点
*/
children?: IVariableTreeNode[];
}
/**
* 服务函数类型
*/

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.0.0-alpha.6](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0-alpha.5...@music163/tango-sandbox@1.0.0-alpha.6) (2024-01-16)
**Note:** Version bump only for package @music163/tango-sandbox
# [1.0.0-alpha.5](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0-alpha.4...@music163/tango-sandbox@1.0.0-alpha.5) (2023-12-29)
**Note:** Version bump only for package @music163/tango-sandbox
# [1.0.0-alpha.4](https://github.com/netease/tango/compare/@music163/tango-sandbox@1.0.0-alpha.3...@music163/tango-sandbox@1.0.0-alpha.4) (2023-12-26)
**Note:** Version bump only for package @music163/tango-sandbox

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-sandbox",
"version": "1.0.0-alpha.4",
"version": "1.0.0-alpha.6",
"description": "sandbox of tango apps",
"author": "wwsun <ww.sun@outlook.com>",
"homepage": "",
@@ -29,8 +29,8 @@
},
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@music163/tango-core": "^1.0.0-alpha.4",
"@music163/tango-helpers": "^1.0.0-alpha.0",
"@music163/tango-core": "^1.0.0-alpha.6",
"@music163/tango-helpers": "^1.0.0-alpha.2",
"crypto-js": "^4.1.1",
"lodash.isequal": "4.5.0",
"react-frame-component": "^5.2.4"

View File

@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.0.0-alpha.7](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.6...@music163/tango-setting-form@1.0.0-alpha.7) (2024-01-23)
### Bug Fixes
- update FormObject style ([#91](https://github.com/netease/tango/issues/91)) ([8fd085d](https://github.com/netease/tango/commit/8fd085d4fd41e588a5d3657add736a1e56088e4a))
# [1.0.0-alpha.6](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.5...@music163/tango-setting-form@1.0.0-alpha.6) (2024-01-16)
**Note:** Version bump only for package @music163/tango-setting-form
# [1.0.0-alpha.5](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.4...@music163/tango-setting-form@1.0.0-alpha.5) (2023-12-29)
**Note:** Version bump only for package @music163/tango-setting-form
# [1.0.0-alpha.4](https://github.com/netease/tango/compare/@music163/tango-setting-form@1.0.0-alpha.3...@music163/tango-setting-form@1.0.0-alpha.4) (2023-12-26)
**Note:** Version bump only for package @music163/tango-setting-form

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-setting-form",
"version": "1.0.0-alpha.4",
"version": "1.0.0-alpha.7",
"description": "setting form of tango-apps",
"author": "wwsun <ww.sun@outlook.com>",
"homepage": "",
@@ -28,9 +28,9 @@
},
"dependencies": {
"@ant-design/icons": "^4.8.0",
"@music163/tango-core": "^1.0.0-alpha.4",
"@music163/tango-helpers": "^1.0.0-alpha.0",
"@music163/tango-ui": "^1.0.0-alpha.2",
"@music163/tango-core": "^1.0.0-alpha.6",
"@music163/tango-helpers": "^1.0.0-alpha.2",
"@music163/tango-ui": "^1.0.0-alpha.5",
"antd": "^4.24.2",
"coral-system": "^1.0.5",
"mobx": "6.12.0",

View File

@@ -103,9 +103,11 @@ export function FormControlGroup({
{extra}
</Box>
}
borderTop="solid"
borderBottom="solid"
border="solid"
borderColor="line.normal"
headerProps={{
bg: 'fill1',
}}
>
<Box
p="m"

View File

@@ -47,7 +47,8 @@ const formStyle = css`
margin-top: var(--tango-space-m);
}
> .SettingFormMain > .FormControl {
> .SettingFormMain > .FormControl,
.FormObject {
margin-left: var(--tango-space-m);
margin-right: var(--tango-space-m);
}

View File

@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.0.0-alpha.5](https://github.com/netease/tango/compare/@music163/tango-ui@1.0.0-alpha.4...@music163/tango-ui@1.0.0-alpha.5) (2024-01-23)
### Bug Fixes
- update FormObject style ([#91](https://github.com/netease/tango/issues/91)) ([8fd085d](https://github.com/netease/tango/commit/8fd085d4fd41e588a5d3657add736a1e56088e4a))
# [1.0.0-alpha.4](https://github.com/netease/tango/compare/@music163/tango-ui@1.0.0-alpha.3...@music163/tango-ui@1.0.0-alpha.4) (2024-01-16)
**Note:** Version bump only for package @music163/tango-ui
# [1.0.0-alpha.3](https://github.com/netease/tango/compare/@music163/tango-ui@1.0.0-alpha.2...@music163/tango-ui@1.0.0-alpha.3) (2023-12-29)
**Note:** Version bump only for package @music163/tango-ui
# [1.0.0-alpha.1](https://github.com/netease/tango/compare/@music163/tango-ui@0.1.12...@music163/tango-ui@1.0.0-alpha.1) (2023-12-12)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@music163/tango-ui",
"version": "1.0.0-alpha.2",
"version": "1.0.0-alpha.5",
"description": "ui widgets of tango",
"keywords": [
"react",
@@ -38,7 +38,7 @@
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.5",
"@music163/tango-helpers": "^1.0.0-alpha.0",
"@music163/tango-helpers": "^1.0.0-alpha.2",
"@uiw/react-codemirror": "^4.21.21",
"antd": "^4.24.2",
"coral-system": "^1.0.5",

View File

@@ -32,6 +32,8 @@ export interface CollapsePanelProps extends Omit<HTMLCoralProps<'div'>, 'title'>
* 是否显示底部边框线
*/
showBottomBorder?: boolean;
headerProps?: HTMLCoralProps<'div'>;
bodyProps?: HTMLCoralProps<'div'>;
}
const headerStyle = css`
@@ -54,6 +56,8 @@ export function CollapsePanel(props: CollapsePanelProps) {
onCollapse,
stickyHeader,
showBottomBorder = true,
headerProps,
bodyProps,
...rest
} = props;
const [collapsed, setCollapsed] = useControllableState({
@@ -92,6 +96,7 @@ export function CollapsePanel(props: CollapsePanelProps) {
onClick={() => setCollapsed(!collapsed)}
p="m"
{...stickHeaderProps}
{...headerProps}
css={headerStyle}
>
<Box display="flex" alignItems="center" fontSize="14px" fontWeight="500">
@@ -100,7 +105,7 @@ export function CollapsePanel(props: CollapsePanelProps) {
</Box>
<Box>{extra}</Box>
</Box>
<Box className="CollapsePanelBody" display={collapsed ? 'none' : 'block'}>
<Box className="CollapsePanelBody" display={collapsed ? 'none' : 'block'} {...bodyProps}>
{children}
</Box>
</Box>