diff --git a/examples/components/EventAction/update-data/SetVariable.jsx b/examples/components/EventAction/update-data/SetVariable.jsx
index 55c3f9bd6ed..0d766290f14 100644
--- a/examples/components/EventAction/update-data/SetVariable.jsx
+++ b/examples/components/EventAction/update-data/SetVariable.jsx
@@ -5,22 +5,34 @@ import update from 'lodash/update';
import isEqual from 'lodash/isEqual';
import {cloneObject, setVariable} from 'amis-core';
-const namespace = 'appVariables';
-const initData = JSON.parse(sessionStorage.getItem(namespace)) || {
- ProductName: 'BCC',
- Banlance: 1234.888,
- ProductNum: 10,
- isOnline: false,
- ProductList: ['BCC', 'BOS', 'VPC'],
- PROFILE: {
- FirstName: 'Amis',
- Age: 18,
- Address: {
- street: 'ShangDi',
- postcode: 100001
- }
+const variables = [
+ {
+ key: 'xxx',
+ defaultValue: 'yyy'
+ },
+
+ {
+ key: 'ProductName',
+ defaultValue: ''
+ },
+
+ {
+ key: 'count',
+ defaultValue: 0,
+ scope: 'page',
+ storageOn: 'client'
+ },
+
+ {
+ key: 'arr',
+ defaultValue: []
+ },
+
+ {
+ key: 'select',
+ defaultValue: ''
}
-};
+];
export default {
/** schema配置 */
@@ -30,7 +42,7 @@ export default {
body: [
{
type: 'tpl',
- tpl: '变量的命名空间通过环境变量设置为了appVariables
, 可以通过\\${appVariables.xxx}来取值'
+ tpl: '变量的命名空间通过环境变量设置为了global
, 可以通过\\${global.xxx}来取值'
},
{
type: 'container',
@@ -43,12 +55,12 @@ export default {
body: [
{
type: 'tpl',
- tpl: '
数据域appVariables
'
+ tpl: '数据域global
'
},
{
type: 'json',
id: 'u:44521540e64c',
- source: '${appVariables}',
+ source: '${global}',
levelExpand: 10
},
{
@@ -59,7 +71,7 @@ export default {
},
{
type: 'tpl',
- tpl: '变量中的ProductName (\\${appVariables.ProductName})
: ${appVariables.ProductName|default:-}
',
+ tpl: '变量中的ProductName (\\${global.ProductName})
: ${global.ProductName|default:-}
',
inline: false,
id: 'u:98ed5c5534ef'
}
@@ -82,7 +94,24 @@ export default {
actions: [
{
args: {
- path: 'appVariables.ProductName',
+ path: 'global.ProductName',
+ value: '${event.data.value}'
+ },
+ actionType: 'setValue'
+ },
+
+ {
+ args: {
+ path: 'global.arr',
+ value:
+ '${[{label: event.data.value, value: event.data.value}]}'
+ },
+ actionType: 'setValue'
+ },
+
+ {
+ args: {
+ path: 'global.select',
value: '${event.data.value}'
},
actionType: 'setValue'
@@ -95,8 +124,39 @@ export default {
type: 'static',
label: '产品名称描述',
id: 'u:7bd4e2a4f95e',
- value: '${appVariables.ProductName}',
+ value: '${global.ProductName}',
name: 'staticName'
+ },
+
+ {
+ type: 'input-number',
+ label: 'Count (client)',
+ description: '存储自动存入客户端,刷新页面后数据还在',
+ id: 'u:7bd4e2a4f95e',
+ value: '${global.count}',
+ name: 'count',
+ onEvent: {
+ change: {
+ weight: 0,
+ actions: [
+ {
+ args: {
+ path: 'global.count',
+ value: '${event.data.value}'
+ },
+ actionType: 'setValue'
+ }
+ ]
+ }
+ }
+ },
+
+ {
+ type: 'select',
+ label: 'select(${global.select})',
+ name: 'select',
+ value: '${global.select}',
+ source: '${global.arr}'
}
],
id: 'u:dc2580fa447a'
@@ -109,7 +169,7 @@ export default {
actions: [
{
args: {
- path: 'appVariables.ProductName',
+ path: 'global.ProductName',
value: '${event.data.ProductName}'
},
actionType: 'setValue'
@@ -119,51 +179,6 @@ export default {
}
},
props: {
- data: {[namespace]: JSON.parse(sessionStorage.getItem(namespace))}
- },
- /** 环境变量 */
- env: {
- beforeSetData: (renderer, action, event) => {
- const value = event?.data?.value ?? action?.args?.value;
- const path = action?.args?.path;
- const {session = 'global'} = renderer.props?.env ?? {};
- const comptList = event?.context?.scoped?.getComponentsByRefPath(
- session,
- path
- );
-
- for (let component of comptList) {
- const {$path: targetPath, $schema: targetSchema} = component?.props;
- const {$path: triggerPath, $schema: triggerSchema} = renderer?.props;
-
- if (
- !component.setData &&
- (targetPath === triggerPath || isEqual(targetSchema, triggerSchema))
- ) {
- continue;
- }
-
- if (component?.props?.onChange) {
- const submitOnChange = !!component.props?.$schema?.submitOnChange;
-
- component.props.onChange(value, submitOnChange, true);
- } else if (component?.setData) {
- const currentData = JSON.parse(
- sessionStorage.getItem(namespace) || JSON.stringify(initData)
- );
- const varPath = path.replace(/^appVariables\./, '');
-
- update(currentData, varPath, origin => {
- return typeof value === typeof origin ? value : origin;
- });
-
- sessionStorage.setItem(namespace, JSON.stringify(currentData));
- const newCtx = cloneObject(component?.props?.data ?? {});
- setVariable(newCtx, path, value, true);
-
- component.setData(newCtx, false);
- }
- }
- }
+ globalVars: variables
}
};
diff --git a/examples/components/Example.jsx b/examples/components/Example.jsx
index 3efa54bcc2b..0487c7b049c 100644
--- a/examples/components/Example.jsx
+++ b/examples/components/Example.jsx
@@ -668,12 +668,7 @@ export const examples = [
{
label: '更新全局变量数据',
path: '/examples/action/setdata/variable',
- component: makeSchemaRenderer(
- SetVariable.schema,
- SetVariable.props ?? {},
- true,
- SetVariable.env
- )
+ component: makeSchemaRenderer(SetVariable)
}
]
},
diff --git a/examples/components/SchemaRender.jsx b/examples/components/SchemaRender.jsx
index 83c29196109..7a6e8b3f58f 100644
--- a/examples/components/SchemaRender.jsx
+++ b/examples/components/SchemaRender.jsx
@@ -42,6 +42,15 @@ export default function (schema, schemaProps, showCode, envOverrides) {
};
}
+ if (!schema.type && schema.schema) {
+ schemaProps = schema.props;
+ envOverrides = schema.env;
+ showCode = schema.showCode ?? true;
+ schema = {
+ ...schema.schema
+ };
+ }
+
return withRouter(
class extends React.Component {
static displayName = 'SchemaRenderer';
diff --git a/packages/amis-core/src/Root.tsx b/packages/amis-core/src/Root.tsx
index 14e78f73e36..af92467ae58 100644
--- a/packages/amis-core/src/Root.tsx
+++ b/packages/amis-core/src/Root.tsx
@@ -16,11 +16,14 @@ import {
StatusScopedWrapper,
StatusScopedProps
} from './StatusScoped';
+import {GlobalVariableItem} from './globalVar';
export interface RootRenderProps {
+ globalVars?: Array;
location?: Location;
theme?: string;
data?: Record;
+ context?: Record;
locale?: string;
[propName: string]: any;
}
diff --git a/packages/amis-core/src/RootRenderer.tsx b/packages/amis-core/src/RootRenderer.tsx
index 847784e6c0d..d7a942431d6 100644
--- a/packages/amis-core/src/RootRenderer.tsx
+++ b/packages/amis-core/src/RootRenderer.tsx
@@ -38,6 +38,7 @@ export class RootRenderer extends React.Component {
this.store.updateContext(props.context);
this.store.initData(props.data);
this.store.updateLocation(props.location, this.props.env?.parseLocation);
+ this.store.setGlobalVars(props.globalVars);
// 将数据里面的函数批量的绑定到 this 上
bulkBindFunctions(this, [
@@ -97,6 +98,11 @@ export class RootRenderer extends React.Component {
componentDidUpdate(prevProps: RootRendererProps) {
const props = this.props;
+ // 更新全局变量
+ if (props.globalVars !== prevProps.globalVars) {
+ this.store.setGlobalVars(props.globalVars);
+ }
+
if (props.location !== prevProps.location) {
this.store.updateLocation(props.location, this.props.env?.parseLocation);
}
@@ -521,7 +527,7 @@ export class RootRenderer extends React.Component {
}
render() {
- const {pathPrefix, schema, render, ...rest} = this.props;
+ const {pathPrefix, schema, render, globalVars, ...rest} = this.props;
const store = this.store;
if (store.runtimeError) {
diff --git a/packages/amis-core/src/SchemaRenderer.tsx b/packages/amis-core/src/SchemaRenderer.tsx
index efbeef0e637..3cf97af1402 100644
--- a/packages/amis-core/src/SchemaRenderer.tsx
+++ b/packages/amis-core/src/SchemaRenderer.tsx
@@ -40,6 +40,8 @@ import {isExpression} from './utils/formula';
import {StatusScopedProps} from './StatusScoped';
import {evalExpression, filter} from './utils/tpl';
import Animations from './components/Animations';
+import {cloneObject} from './utils/object';
+import {observeGlobalVars} from './globalVar';
interface SchemaRendererProps
extends Partial>,
@@ -101,6 +103,8 @@ export class SchemaRenderer extends React.Component {
schema: any;
path: string;
+ tmpData: any;
+
toDispose: Array<() => any> = [];
unbindEvent: (() => void) | undefined = undefined;
unbindGlobalEvent: (() => void) | undefined = undefined;
@@ -114,13 +118,16 @@ export class SchemaRenderer extends React.Component {
this.reRender = this.reRender.bind(this);
this.resolveRenderer(this.props);
this.dispatchEvent = this.dispatchEvent.bind(this);
+ this.handleGlobalVarChange = this.handleGlobalVarChange.bind(this);
+
+ const schema = props.schema;
// 监听statusStore更新
this.toDispose.push(
reaction(
() => {
- const id = filter(props.schema.id, props.data);
- const name = filter(props.schema.name, props.data);
+ const id = filter(schema.id, props.data);
+ const name = filter(schema.name, props.data);
return `${
props.statusStore.visibleState[id] ??
props.statusStore.visibleState[name]
@@ -135,6 +142,10 @@ export class SchemaRenderer extends React.Component {
() => this.forceUpdate()
)
);
+
+ this.toDispose.push(
+ observeGlobalVars(schema, props.topStore, this.handleGlobalVarChange)
+ );
}
componentWillUnmount() {
@@ -172,6 +183,21 @@ export class SchemaRenderer extends React.Component {
return false;
}
+ handleGlobalVarChange() {
+ const handler = this.renderer?.onGlobalVarChanged;
+ const newData = cloneObject(this.props.data);
+
+ // 如果渲染器自己做了实现,且返回 false,则不再继续往下执行
+ if (handler?.(this.cRef, this.props.schema, newData) === false) {
+ return;
+ }
+
+ this.tmpData = newData;
+ this.forceUpdate(() => {
+ delete this.tmpData;
+ });
+ }
+
resolveRenderer(props: SchemaRendererProps, force = false): any {
let schema = props.schema;
let path = props.$path;
@@ -308,7 +334,10 @@ export class SchemaRenderer extends React.Component {
? evalExpression(_.staticOn, rest.data)
: _.static ?? rest.defaultStatic),
...subProps,
- data: subProps.data || rest.data,
+ data:
+ this.tmpData && subProps.data === this.props.data
+ ? this.tmpData
+ : subProps.data || rest.data,
env: env
});
}
@@ -517,6 +546,9 @@ export class SchemaRenderer extends React.Component {
mobileUI: schema.useMobileUI === false ? false : rest.mobileUI
};
+ // 用于全局变量刷新
+ props.data = this.tmpData || props.data;
+
// style 支持公式
if (schema.style) {
(props as any).style = buildStyle(schema.style, detectData);
diff --git a/packages/amis-core/src/actions/CmptAction.ts b/packages/amis-core/src/actions/CmptAction.ts
index 8591833cb87..4d86bb840e7 100644
--- a/packages/amis-core/src/actions/CmptAction.ts
+++ b/packages/amis-core/src/actions/CmptAction.ts
@@ -42,6 +42,11 @@ export class CmptAction implements RendererAction {
/** 如果args中携带path参数, 则认为是全局变量赋值, 否则认为是组件变量赋值 */
if (action.actionType === 'setValue' && path && typeof path === 'string') {
+ if (path.startsWith('global.')) {
+ const topStore = renderer.props.topStore;
+ topStore?.updateGlobalVarValue(path.substring(7), action.args.value);
+ }
+
const beforeSetData = event?.context?.env?.beforeSetData;
if (beforeSetData && typeof beforeSetData === 'function') {
const res = await beforeSetData(renderer, action, event);
diff --git a/packages/amis-core/src/factory.tsx b/packages/amis-core/src/factory.tsx
index d034707bf83..f263d9b62cb 100644
--- a/packages/amis-core/src/factory.tsx
+++ b/packages/amis-core/src/factory.tsx
@@ -56,6 +56,14 @@ export interface RendererBasicConfig {
prevProps: any
) => boolean | undefined;
storeExtendsData?: boolean | ((props: any) => boolean); // 是否需要继承上层数据。
+ // 当全局渲染器关联的全局变量发生变化时执行
+ // 因为全局变量永远都是最新的,有些组件是 didUpdate 的时候比对有变化才更新
+ // 这里给组件一个自定义更新的机会
+ onGlobalVarChanged?: (
+ instance: React.Component,
+ schema: any,
+ data: any
+ ) => void | boolean;
weight?: number; // 权重,值越低越优先命中。
isolateScope?: boolean;
isFormItem?: boolean;
diff --git a/packages/amis-core/src/globalVar.ts b/packages/amis-core/src/globalVar.ts
new file mode 100644
index 00000000000..29b2bbf05c5
--- /dev/null
+++ b/packages/amis-core/src/globalVar.ts
@@ -0,0 +1,350 @@
+/**
+ * 全局变量
+ *
+ * 用于实现跨组件、跨页面的数据共享,支持持久化。
+ */
+
+import {JSONSchema} from './types';
+import {isExpression} from './utils/formula';
+import {reaction} from 'mobx';
+import {resolveVariableAndFilter} from './utils/resolveVariableAndFilter';
+import {IRootStore} from './store/root';
+
+/**
+ * 全局变量的定义
+ */
+export interface GlobalVariableItem {
+ // 标识,用于区分不同的变量,不可重复
+ id?: string;
+
+ /**
+ * 设计态属性。
+ * 全局变量类型,不是数据体类型,而是用于区分不同的配置方式。
+ * 这个字段运行时无含义,主要用于编辑器中的分类。
+ *
+ * 比如平台可能扩充:应用级别、页面级别、用户级别、数据字典关联、数据模型关联等。
+ * amis 中内置了:builtin 为简单的客户端存储,服务端存储需要外部扩充。
+ *
+ * @default builtin
+ */
+ type?: string | 'builtin';
+
+ /**
+ * 变量名
+ */
+ key: string;
+
+ /**
+ * 变量标题
+ */
+ label?: string;
+
+ /**
+ * 变量描述
+ */
+ description?: string;
+
+ /**
+ * 默认值
+ */
+ defaultValue?: string;
+
+ /**
+ * 值的数据类型定义
+ */
+ valueSchema?: JSONSchema;
+
+ /**
+ * 数据作用域
+ */
+ scope?: 'page' | 'app';
+
+ /**
+ * 数据存储方式
+ */
+ storageOn?: 'client' | 'server' | 'session';
+
+ /**
+ * 是否只读,不允许修改,运行态属性。
+ */
+ readonly?: boolean;
+
+ /**
+ * @default true
+ * 是否自动保存,如果不特殊配置,会自动保存
+ */
+ autoSave?: boolean;
+
+ // todo: 以下是扩展字段,需要进一步定义
+ validationErrors?: any;
+ validations?: any;
+
+ // 其他扩充数据
+ [propName: string]: any;
+}
+
+export type GlobalVarGetter = (
+ variable: GlobalVariableItem,
+ context: GlobalVarContext
+) => Promise;
+export type GlobalVarBulkGetter = (
+ context: GlobalVarContext
+) => Promise> | Record;
+export type GlobalVarSetter = (
+ variable: GlobalVariableItem,
+ value: Record,
+ context: GlobalVarContext
+) => Promise;
+export type GlobalVarBulkSetter = (
+ values: Record,
+ context: GlobalVarContext
+) => Promise | any;
+
+export interface GlobalVariableItemFull extends GlobalVariableItem {
+ /**
+ * 校验数值是否合法
+ * @param value
+ * @returns
+ */
+ validate?: (value: any, values: Record) => string | void;
+
+ /**
+ * 权重,用于排序
+ */
+ order?: number;
+
+ /**
+ * 单个数据初始化获取
+ * @returns
+ */
+ getter?: GlobalVarGetter;
+
+ /**
+ * 批量数据初始化获取
+ * @returns
+ */
+ bulkGetter?: GlobalVarBulkGetter;
+
+ /**
+ * 当个数据修改保存
+ * @param value
+ * @returns
+ */
+ setter?: GlobalVarSetter;
+
+ /**
+ * 批量全局变量数据保存,多个变量一起保存
+ * @param values
+ * @returns
+ */
+ bulkSetter?: GlobalVarBulkSetter;
+}
+
+/**
+ * 全局变量的状态
+ */
+export interface GlobalVariableState {
+ /**
+ * 当前值
+ */
+ value: any;
+
+ /**
+ * 原始值
+ */
+ pristine: any;
+
+ /**
+ * 是否正在加载, 或者说正在获取
+ */
+ busy?: boolean;
+
+ /**
+ * 是否已经初始化
+ */
+ initialized: boolean;
+
+ /**
+ * 数据是否合法
+ */
+ valid: boolean;
+
+ /**
+ * 错误馨馨
+ */
+ errorMessages: string[];
+
+ /**
+ * 是否有变更
+ */
+ touched: boolean;
+
+ /**
+ * 是否有保存
+ */
+ saved: boolean;
+}
+
+export interface GlobalVarContext {
+ variables: Array;
+ [propName: string]: any;
+}
+/**
+ * 全局变量处理器, 可以处理全变量的初始化、校验、存储等操作
+ */
+export type GlobalVariableHandler = (
+ variable: GlobalVariableItem | GlobalVariableItemFull,
+ context: GlobalVarContext
+) =>
+ | GlobalVariableItemFull
+ | void
+ | ((
+ variable: GlobalVariableItem | GlobalVariableItemFull,
+ context: GlobalVarContext
+ ) => GlobalVariableItemFull | void);
+
+const handlers: Array = [];
+
+/**
+ * 注册全局变量处理器
+ * @param handler
+ */
+export function registerGlobalVariableHandler(handler: GlobalVariableHandler) {
+ handlers.push(handler);
+}
+
+/**
+ * 通过处理器构建全局变量细节对象,用于后续的初始化、校验、存储等操作
+ * @param variable
+ * @param context
+ * @returns
+ */
+export function buildGlobalVariable(
+ variable: GlobalVariableItem | GlobalVariableItemFull,
+ context: GlobalVarContext
+): GlobalVariableItemFull {
+ const postHandlers: Array<
+ (
+ variable: GlobalVariableItem | GlobalVariableItemFull,
+ context: GlobalVarContext
+ ) => GlobalVariableItemFull | void
+ > = [];
+
+ let result = handlers.reduce((item, handler) => {
+ const result = handler(item, context);
+
+ if (typeof result === 'function') {
+ postHandlers.push(result);
+ }
+
+ return (result ?? item) as GlobalVariableItemFull;
+ }, variable);
+
+ result = postHandlers.reduce(
+ (item, handler) => handler(item, context) ?? item,
+ result
+ );
+
+ return result;
+}
+
+/**
+ * 构建变量初始状态
+ * @returns
+ */
+export function createGlobalVarState(): GlobalVariableState {
+ return {
+ value: undefined,
+ pristine: undefined,
+ initialized: false,
+ valid: true,
+ errorMessages: [],
+ touched: false,
+ saved: false
+ };
+}
+
+// 前两个是历史遗留,后两个是新的
+const globalVarFields = ['__page.', 'appVariables.', 'global.', 'globalState.'];
+export function isGlobalVarExpression(value: string) {
+ return (
+ typeof value === 'string' &&
+ isExpression(value) &&
+ globalVarFields.some(k => value.includes(k))
+ );
+}
+
+/**
+ * 监控组件的全局变量
+ * @param schema
+ * @param topStore
+ * @param callback
+ * @returns
+ */
+export function observeGlobalVars(
+ schema: any,
+ topStore: IRootStore,
+ callback: () => void
+) {
+ let expressions: Array<{
+ key: string;
+ value: string;
+ }> = [];
+ Object.keys(schema).forEach(key => {
+ const value = schema[key];
+
+ if (isGlobalVarExpression(value)) {
+ expressions.push({
+ key,
+ value
+ });
+ } else if (
+ [
+ 'items',
+ 'body',
+ 'buttons',
+ 'header',
+ 'columns',
+ 'tabs',
+ 'footer',
+ 'actions',
+ 'toolbar'
+ ].includes(key)
+ ) {
+ const items = Array.isArray(value) ? value : [value];
+ items.forEach(item => {
+ if (isGlobalVarExpression(item?.visibleOn)) {
+ expressions.push({
+ key: `${key}.x.visibleOn`,
+ value: item.visibleOn
+ });
+ } else if (isGlobalVarExpression(item?.hiddenOn)) {
+ expressions.push({
+ key: `${key}.x.hiddenOn`,
+ value: item.hiddenOn
+ });
+ }
+ });
+ }
+ });
+ if (!expressions.length) {
+ return () => {};
+ }
+
+ const unReaction = reaction(
+ () =>
+ expressions
+ .map(
+ exp =>
+ `${exp.key}:${resolveVariableAndFilter(
+ exp.value,
+ topStore.downStream,
+ '| json' // 如果用了复杂对象,要靠这个来比较
+ )}`
+ )
+ .join(','),
+ callback
+ );
+
+ return unReaction;
+}
diff --git a/packages/amis-core/src/globalVarClientHandler.ts b/packages/amis-core/src/globalVarClientHandler.ts
new file mode 100644
index 00000000000..23d2093ba38
--- /dev/null
+++ b/packages/amis-core/src/globalVarClientHandler.ts
@@ -0,0 +1,84 @@
+import {
+ registerGlobalVariableHandler,
+ GlobalVariableItemFull,
+ GlobalVarContext,
+ GlobalVariableItem
+} from './globalVar';
+
+function loadClientData(key: string, variables: Array) {
+ const str = localStorage.getItem(key);
+ let data: any = {};
+
+ try {
+ data = JSON.parse(str || '{}');
+ } catch (e) {
+ console.error(`parse localstorage "${key} error"`, e);
+ }
+
+ let filterData: any = {};
+ variables.forEach(item => {
+ if (data.hasOwnProperty(item.key)) {
+ filterData[item.key] = data[item.key];
+ }
+ });
+
+ return filterData;
+}
+
+function saveClientData(
+ key: string,
+ values: any,
+ variables: Array
+) {
+ const str = localStorage.getItem(key);
+ let data: any = {};
+
+ try {
+ data = JSON.parse(str || '{}');
+ } catch (e) {
+ console.error(`parse localstorage "${key} error"`, e);
+ }
+ Object.assign(data, values);
+ localStorage.setItem(key, JSON.stringify(data));
+}
+
+function bulkClientGetter({variables}: GlobalVarContext) {
+ return loadClientData('amis-client-vars', variables);
+}
+
+function bulkClientSetter(values: any, context: GlobalVarContext) {
+ return saveClientData('amis-client-vars', values, context.variables);
+}
+
+function pageBulkClientGetter(context: GlobalVarContext) {
+ const variables = context.variables;
+ const key = `amis-client-vars-${context.pageId || location.pathname}`;
+ return loadClientData(key, variables);
+}
+
+function pageBulkClientSetter(values: any, context: GlobalVarContext) {
+ const key = `amis-client-vars-${context.pageId || location.pathname}`;
+ return saveClientData(key, values, context.variables);
+}
+
+/**
+ * 注册全局变量处理器,用来处理 storageOn 为 client 的变量
+ *
+ * 并且根据变量的 scope 来决定是 page 还是全局的
+ *
+ * 将数据存入 localStorage
+ */
+registerGlobalVariableHandler(function (
+ variable,
+ context
+): GlobalVariableItemFull | void {
+ if (variable.storageOn === 'client') {
+ return {
+ ...variable,
+ bulkGetter:
+ variable.scope === 'page' ? pageBulkClientGetter : bulkClientGetter,
+ bulkSetter:
+ variable.scope === 'page' ? pageBulkClientSetter : bulkClientSetter
+ };
+ }
+});
diff --git a/packages/amis-core/src/globalVarDefaultValueHandler.ts b/packages/amis-core/src/globalVarDefaultValueHandler.ts
new file mode 100644
index 00000000000..e0ddde2464d
--- /dev/null
+++ b/packages/amis-core/src/globalVarDefaultValueHandler.ts
@@ -0,0 +1,30 @@
+import {
+ registerGlobalVariableHandler,
+ GlobalVariableItemFull
+} from './globalVar';
+/**
+ * 格式化默认值
+ */
+registerGlobalVariableHandler(function (
+ variable,
+ context
+): GlobalVariableItemFull | void {
+ if (variable.defaultValue && typeof variable.defaultValue === 'string') {
+ const defaultValue = variable.defaultValue;
+ try {
+ let value = defaultValue;
+ const valueType = variable.valueSchema?.type as string;
+
+ if (valueType && ['number', 'array', 'object'].includes(valueType)) {
+ value = JSON.parse(defaultValue);
+ }
+
+ return {
+ ...variable,
+ defaultValue: value
+ };
+ } catch (e) {
+ // do nothing
+ }
+ }
+});
diff --git a/packages/amis-core/src/index.tsx b/packages/amis-core/src/index.tsx
index 139e77d3040..641b3315004 100644
--- a/packages/amis-core/src/index.tsx
+++ b/packages/amis-core/src/index.tsx
@@ -39,7 +39,10 @@ export * from './utils/index';
export * from './utils/animations';
export * from './types';
export * from './store';
+export * from './globalVar';
import * as utils from './utils/helper';
+import './globalVarClientHandler';
+import './globalVarDefaultValueHandler';
import {getEnv} from 'mobx-state-tree';
import {RegisterStore, registerStore, RendererStore} from './store';
diff --git a/packages/amis-core/src/renderers/Item.tsx b/packages/amis-core/src/renderers/Item.tsx
index ab62a8da5c3..b714b638ed2 100644
--- a/packages/amis-core/src/renderers/Item.tsx
+++ b/packages/amis-core/src/renderers/Item.tsx
@@ -3,6 +3,8 @@ import hoistNonReactStatic from 'hoist-non-react-statics';
import {IFormItemStore, IFormStore} from '../store/form';
import {reaction} from 'mobx';
import {isAlive} from 'mobx-state-tree';
+import {isGlobalVarExpression} from '../globalVar';
+import {resolveVariableAndFilter} from '../utils/resolveVariableAndFilter';
import {
renderersMap,
@@ -2361,7 +2363,24 @@ export function registerFormItem(config: FormItemConfig): RendererConfig {
...config,
weight: typeof config.weight !== 'undefined' ? config.weight : -100, // 优先级高点
component: Control as any,
- isFormItem: true
+ isFormItem: true,
+ onGlobalVarChanged: function (instance, schema, data): any {
+ if (config.onGlobalVarChanged?.apply(this, arguments) === false) {
+ return false;
+ }
+
+ if (isGlobalVarExpression(schema.source)) {
+ (instance.props as any).reloadOptions?.();
+ }
+
+ // 目前表单项的全局变量更新要靠这个方式
+ if (isGlobalVarExpression(schema.value)) {
+ (instance.props as any).onChange(
+ resolveVariableAndFilter(schema.value, data, '| raw')
+ );
+ return false;
+ }
+ }
});
}
diff --git a/packages/amis-core/src/store/root.ts b/packages/amis-core/src/store/root.ts
index 3d1feeb8853..600301a6055 100644
--- a/packages/amis-core/src/store/root.ts
+++ b/packages/amis-core/src/store/root.ts
@@ -6,25 +6,87 @@ import {
extractObjectChain,
isObjectShallowModified
} from '../utils';
+import {
+ GlobalVariableItem,
+ GlobalVariableState,
+ GlobalVariableItemFull,
+ buildGlobalVariable,
+ createGlobalVarState,
+ GlobalVarContext,
+ GlobalVarGetter,
+ GlobalVarBulkGetter,
+ GlobalVarSetter,
+ GlobalVarBulkSetter
+} from '../globalVar';
+import isPlainObject from 'lodash/isPlainObject';
+import debounce from 'lodash/debounce';
export const RootStore = ServiceStore.named('RootStore')
.props({
runtimeError: types.frozen(),
runtimeErrorStack: types.frozen(),
query: types.frozen(),
- ready: false
+ ready: false,
+ globalVarStates: types.optional(
+ types.map(types.frozen()),
+ {}
+ )
})
.volatile(self => {
return {
- context: {}
+ context: {},
+ globalVars: [] as Array,
+ globalData: {
+ global: {},
+ globalState: {}
+ } as any
};
})
.views(self => ({
get downStream() {
let result = self.data;
- if (self.context || self.query) {
+ if (self.context || self.query || self.globalVarStates.size) {
const chain = extractObjectChain(result);
+
+ // 数据链中添加 global 和 globalState
+ // 对应的是全局变量的值和全局变量的状态
+ if (self.globalVarStates.size) {
+ const globalData = {} as any;
+ let touched = false;
+ let saved = true;
+ let errors: any = {};
+ let initialized = true;
+ self.globalVarStates.forEach((state, key) => {
+ globalData[key] = state.value;
+ touched = touched || state.touched;
+ if (!state.saved) {
+ saved = false;
+ }
+
+ if (state.errorMessages.length) {
+ errors[key] = state.errorMessages;
+ }
+ if (!state.initialized) {
+ initialized = false;
+ }
+ });
+
+ // 保存全局变量的值和状态
+ Object.assign(self.globalData.global, globalData);
+ Object.assign(self.globalData.globalState, {
+ fields: self.globalVarStates.toJSON(),
+ initialized: initialized,
+ touched: touched,
+ saved: saved,
+ errors: errors,
+ valid: !Object.keys(errors).length
+ });
+
+ // self.globalData 一直都是那个对象,这样组件里面始终拿到的都是最新的
+ chain.unshift(self.globalData);
+ }
+
self.context && chain.unshift(self.context);
self.query &&
chain.splice(chain.length - 1, 0, {
@@ -39,6 +101,10 @@ export const RootStore = ServiceStore.named('RootStore')
}
}))
.actions(self => {
+ function updateState(key: string, state: Partial) {
+ return (self as any).updateGlobalVarState(key, state);
+ }
+
const init: (schema: any) => Promise = flow(function* init(
fn: () => Promise
) {
@@ -47,16 +113,447 @@ export const RootStore = ServiceStore.named('RootStore')
if (ret?.then) {
yield ret;
}
+ } catch (e) {
+ self.runtimeError = e.message;
+ self.runtimeErrorStack = e.stack;
} finally {
self.ready = true;
}
});
+
+ /**
+ * 比较新旧变量列表的差异
+ * @param vars 新的变量列表
+ * @param originVars 原始变量列表
+ * @returns 返回新增、更新和删除的变量列表
+ */
+ function diffVariables(
+ vars: GlobalVariableItem[],
+ originVars: GlobalVariableItem[]
+ ) {
+ const removeVars: Array = originVars.concat();
+ const updateVars: Array = [];
+ const newVars: Array = [];
+
+ for (let varItem of vars) {
+ const idx = removeVars.findIndex(item => item.key === varItem.key);
+
+ if (~idx) {
+ const [origin] = removeVars.splice(idx, 1);
+
+ if (origin.id !== varItem.id) {
+ updateVars.push(varItem);
+ }
+ } else {
+ newVars.push(varItem);
+ }
+ }
+
+ return {
+ newVars,
+ updateVars,
+ removeVars
+ };
+ }
+
+ /**
+ * 初始化单个全局变量
+ */
+ async function initGlobalVarData(
+ item: GlobalVariableItemFull,
+ context: GlobalVarContext,
+ getter?: GlobalVarGetter
+ ) {
+ let value = item.defaultValue;
+
+ if (getter) {
+ await getGlobalVarData(item, context, getter);
+ const state = self.globalVarStates.get(item.key)!;
+ updateState(item.key, {
+ initialized: true,
+ pristine: state.value
+ });
+ } else {
+ updateState(item.key, {
+ value,
+ pristine: value,
+ initialized: item.bulkGetter ? false : true
+ });
+ }
+ }
+
+ /**
+ * 批量初始化全局变量
+ */
+ async function getGlobalVarData(
+ item: GlobalVariableItemFull,
+ context: GlobalVarContext,
+ getter: GlobalVarGetter
+ ) {
+ try {
+ updateState(item.key, {
+ busy: true
+ });
+ const value = await getter(item, context);
+ updateState(item.key, {
+ value
+ });
+ } finally {
+ updateState(item.key, {
+ busy: false
+ });
+ }
+ }
+
+ /**
+ * 批量初始化全局变量
+ */
+ async function bulkGetGlobalVarData(
+ variables: Array,
+ context: GlobalVarContext,
+ getter: GlobalVarBulkGetter
+ ) {
+ try {
+ variables.forEach(item => {
+ updateState(item.key, {
+ busy: true
+ });
+ });
+ const data = await getter.call(null, {
+ ...context,
+ variables
+ });
+
+ if (!isPlainObject(data)) {
+ return;
+ }
+
+ for (let key in data) {
+ // 返回非定义部分的数据不处理
+ if (!variables.some(item => item.key === key)) {
+ continue;
+ }
+
+ const state = self.globalVarStates.get(key);
+ if (state) {
+ updateState(key, {
+ value: data[key],
+ pristine: data[key],
+ initialized: true
+ });
+ }
+ }
+ } finally {
+ variables.forEach(item => {
+ updateState(item.key, {
+ busy: false
+ });
+ });
+ }
+ }
+
+ /**
+ * 初始化全局变量
+ */
+ async function initializeGlobalVars(
+ newVars: GlobalVariableItem[],
+ updateVars: GlobalVariableItem[]
+ ) {
+ const variables = newVars.concat(updateVars);
+ const context = {
+ ...self.context,
+ variables
+ };
+ const globalVars = variables.map(item =>
+ buildGlobalVariable(item, context)
+ );
+
+ const bulkGetters: Array<{
+ fn: GlobalVarBulkGetter;
+ variables: Array;
+ }> = [];
+ const itemsNotInitialized: Array = [];
+
+ for (let item of globalVars) {
+ let state = self.globalVarStates.get(item.key);
+ if (state?.initialized) {
+ continue;
+ }
+
+ itemsNotInitialized.push(item);
+
+ if (item.bulkGetter) {
+ let getter = bulkGetters.find(a => a.fn === item.bulkGetter);
+ if (!getter) {
+ getter = {
+ fn: item.bulkGetter,
+ variables: []
+ };
+ bulkGetters.push(getter);
+ }
+ getter.variables.push(item);
+ }
+ }
+
+ // 先单个初始化
+ await Promise.all(
+ itemsNotInitialized.map(item =>
+ initGlobalVarData(item, context, item.getter)
+ )
+ );
+ // 再批量初始化
+ await Promise.all(
+ bulkGetters.map(({fn, variables}) =>
+ bulkGetGlobalVarData(variables, context, fn)
+ )
+ );
+
+ return globalVars;
+ }
+
+ // 设置全局变量,返回一个Promise
+ const setGlobalVars: (vars?: Array) => Promise =
+ flow(function* setGlobalVars(vars?: Array) {
+ const {newVars, updateVars, removeVars} = diffVariables(
+ vars || [],
+ self.globalVars
+ );
+
+ // 初始化全局变量
+ self.globalVars = yield initializeGlobalVars(newVars, updateVars);
+
+ removeVars.forEach(item => {
+ self.globalVarStates.delete(item.key);
+ });
+ });
+
+ // 更新全局变量的值
+ const updateGlobalVarValue = (key: string, value: any) => {
+ return modifyGlobalVarValue(key, {op: 'set', value});
+ };
+
+ // 如果对应变量是个对象,那么可以通过这个扩充变量的值
+ const modifyGlobalVarValue = (
+ key: string,
+ options: {
+ op:
+ | 'set'
+ | 'merge'
+ | 'push'
+ | 'unshift'
+ | 'remove'
+ | 'toggle'
+ | 'empty'
+ | 'sort'
+ | 'reverse'
+ | 'refresh';
+ value: any;
+ }
+ ) => {
+ const state = self.globalVarStates.get(key);
+ if (!state) {
+ return;
+ }
+
+ let value = state.value;
+
+ switch (options.op) {
+ case 'set':
+ value = options.value;
+ break;
+ // 下面这些以后使用的地方再加
+ // case 'merge':
+ // value = {
+ // ...isPlainObject(value) ? value : {},
+ // ...options.value
+ // };
+ // break;
+ // case 'push':
+ // value = Array.isArray(value) ? value.concat() : [];
+ // value = value.concat(options.value);
+ // break;
+ // case 'unshift':
+ // value = Array.isArray(value) ? value.concat() : [];
+ // value.unshift(options.value);
+ // break;
+ // case 'remove':
+ // value = (Array.isArray(value) ? value : []).filter((item: any) => item !== options.value);
+ // break;
+ // case 'toggle':
+ // value = value === options.value ? undefined : options.value;
+ // break;
+ // case 'empty':
+ // value = [];
+ // break;
+ // case 'sort':
+ // value = value.sort(options.value);
+ // break;
+ // case 'reverse':
+ // value = value.reverse();
+ // break;
+ default:
+ break;
+ }
+
+ updateState(key, {
+ value,
+ touched: true
+ });
+
+ lazySaveGlobalVarValues();
+ };
+
+ /**
+ * 保存单个全局变量的值
+ */
+ async function saveGlobalVarData(
+ item: GlobalVariableItemFull,
+ value: any,
+ context: GlobalVarContext,
+ setter: GlobalVarSetter
+ ) {
+ try {
+ updateState(item.key, {
+ busy: true
+ });
+
+ await setter(item, value, context);
+
+ updateState(item.key, {
+ saved: true
+ });
+ } finally {
+ updateState(item.key, {
+ busy: false
+ });
+ }
+ }
+
+ /**
+ * 批量保存全局变量
+ */
+ async function bulkSaveGlobalVarData(
+ variables: Array,
+ values: any,
+ context: GlobalVarContext,
+ setter: GlobalVarBulkSetter
+ ) {
+ try {
+ variables.forEach(item => {
+ updateState(item.key, {
+ busy: true
+ });
+ });
+ await setter(values, {
+ ...context,
+ variables
+ });
+
+ variables.forEach(item => {
+ updateState(item.key, {
+ saved: true
+ });
+ });
+ } finally {
+ variables.forEach(item => {
+ updateState(item.key, {
+ busy: false
+ });
+ });
+ }
+ }
+
+ /**
+ * 保存全局变量的值
+ */
+ async function saveGlobalVarValues(key?: string) {
+ const context = {
+ ...self.context,
+ variables: self.globalVars
+ };
+ const setters: Array<{
+ fn: GlobalVarSetter;
+ value: any;
+ item: GlobalVariableItem;
+ }> = [];
+ const bulkSetters: Array<{
+ fn: GlobalVarBulkSetter;
+ variables: Array;
+ values: any;
+ }> = [];
+ const values: any = {};
+ for (let varItem of self.globalVars) {
+ const state = self.globalVarStates.get(varItem.key);
+ if (!state?.touched) {
+ continue;
+ } else if (key && key !== varItem.key) {
+ continue;
+ } else if (!key && varItem.autoSave === false) {
+ // 没有指定 key,但是 autoSave 为 false 的不保存
+ continue;
+ }
+
+ values[varItem.key] = state.value;
+ if (varItem.setter) {
+ setters.push({
+ fn: varItem.setter,
+ item: varItem,
+ value: state.value
+ });
+ }
+
+ if (varItem.bulkSetter) {
+ let setter = bulkSetters.find(a => a.fn === varItem.bulkSetter);
+ if (!setter) {
+ setter = {
+ fn: varItem.bulkSetter,
+ variables: [],
+ values: {}
+ };
+ bulkSetters.push(setter);
+ }
+ setter.variables.push(varItem);
+ setter.values[varItem.key] = state.value;
+ }
+ }
+
+ await Promise.all(
+ setters
+ .map(({fn, item, value}) =>
+ saveGlobalVarData(item, value, context, fn)
+ )
+ .concat(
+ bulkSetters.map(({variables, values, fn}) =>
+ bulkSaveGlobalVarData(variables, values, context, fn)
+ )
+ )
+ );
+ }
+
+ // 延迟保存全局变量的值
+ const lazySaveGlobalVarValues = debounce(saveGlobalVarValues, 250, {
+ trailing: true,
+ leading: false
+ });
+
return {
updateContext(context: any) {
// 因为 context 不是受控属性,直接共用引用好了
// 否则还会触发孩子节点的重新渲染
Object.assign(self.context, context);
},
+ updateGlobalVarState(key: string, state: Partial) {
+ const origin = self.globalVarStates.get(key);
+ const newState = {
+ ...(origin || createGlobalVarState()),
+ ...state
+ };
+ self.globalVarStates.set(key, newState as any);
+ },
+ setGlobalVars,
+ updateGlobalVarValue,
+ modifyGlobalVarValue,
+ saveGlobalVarValues: lazySaveGlobalVarValues,
setRuntimeError(error: any, errorStack: any) {
self.runtimeError = error;
self.runtimeErrorStack = errorStack;
@@ -67,7 +564,10 @@ export const RootStore = ServiceStore.named('RootStore')
self.query = query;
}
},
- init: init
+ init: init,
+ afterDestroy() {
+ lazySaveGlobalVarValues.flush();
+ }
};
});
diff --git a/packages/amis-editor-core/scss/control/_global-var.scss b/packages/amis-editor-core/scss/control/_global-var.scss
new file mode 100644
index 00000000000..ce70de821d3
--- /dev/null
+++ b/packages/amis-editor-core/scss/control/_global-var.scss
@@ -0,0 +1,63 @@
+.ae-GlobalVarPanel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > .panel-header {
+ flex-shrink: 0;
+ }
+}
+
+.ae-GlobalVarManager {
+ border-top: 1px solid #e8e9eb;
+ padding: 12px;
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ @include minScrollBar();
+
+ & > ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ &-empty {
+ color: #b4b6ba;
+ text-align: center;
+ vertical-align: middle;
+ }
+
+ &-AddBtn {
+ margin-top: 12px;
+ // position: sticky;
+ // bottom: 0;
+ }
+}
+
+.ae-GlobalVarItem {
+ padding: 0;
+ display: flex;
+ height: 34px;
+ line-height: 32px;
+ margin: 0 0 12px;
+
+ &-info {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border-radius: 4px;
+ padding: 0 10px;
+ border: 1px solid #e8e9eb;
+ margin-right: 10px;
+ }
+
+ &-actions {
+ width: 64px;
+ flex-shrink: 0;
+
+ & > button {
+ border: 0 !important;
+ }
+ }
+}
diff --git a/packages/amis-editor-core/scss/editor.scss b/packages/amis-editor-core/scss/editor.scss
index e3528c00ff3..79e8129a5ef 100644
--- a/packages/amis-editor-core/scss/editor.scss
+++ b/packages/amis-editor-core/scss/editor.scss
@@ -43,6 +43,7 @@
@import './control/_flex-setting-control';
@import './control/table-column-width-control.scss';
@import './control/crud2-control';
+@import './control/global-var';
/* 样式控件 */
@import './style-control/box-model';
@@ -1111,6 +1112,7 @@
.ae-RendererPanel,
.ae-Outline-panel,
.ae-InsertPanel,
+.ae-GlobalVarPanel,
.ae-CodePanel {
// 左侧面板Header
.panel-header {
@@ -1137,6 +1139,7 @@
display: flex;
justify-content: flex-start;
align-items: center;
+ flex-shrink: 0;
&:hover {
border-color: $editor-active-color !important;
diff --git a/packages/amis-editor-core/src/component/IFramePreview.tsx b/packages/amis-editor-core/src/component/IFramePreview.tsx
index dcc28bbb307..a0a62f5cea1 100644
--- a/packages/amis-editor-core/src/component/IFramePreview.tsx
+++ b/packages/amis-editor-core/src/component/IFramePreview.tsx
@@ -113,6 +113,7 @@ export default class IFramePreview extends React.Component {
{render(
editable ? store.filteredSchema : store.filteredSchemaForPreview,
{
+ globalVars: store.globalVariables,
...rest,
key: editable ? 'edit-mode' : 'preview-mode',
theme: env.theme,
diff --git a/packages/amis-editor-core/src/component/Preview.tsx b/packages/amis-editor-core/src/component/Preview.tsx
index 12b441aaa11..91c5097cf60 100644
--- a/packages/amis-editor-core/src/component/Preview.tsx
+++ b/packages/amis-editor-core/src/component/Preview.tsx
@@ -720,6 +720,7 @@ class SmartPreview extends React.Component {
{render(
editable ? store.filteredSchema : store.filteredSchemaForPreview,
{
+ globalVars: store.globalVariables,
...rest,
key: editable ? 'edit-mode' : 'preview-mode',
theme: env.theme,
diff --git a/packages/amis-editor-core/src/component/base/SchemaForm.tsx b/packages/amis-editor-core/src/component/base/SchemaForm.tsx
index 433bb846d57..5475f45991f 100644
--- a/packages/amis-editor-core/src/component/base/SchemaForm.tsx
+++ b/packages/amis-editor-core/src/component/base/SchemaForm.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import {EditorNodeType} from '../../store/node';
import {EditorManager} from '../../manager';
import {diff, getThemeConfig} from '../../util';
+import pick from 'lodash/pick';
import {
createObjectFromChain,
createObject,
@@ -13,177 +14,224 @@ import {
import omit from 'lodash/omit';
import cx from 'classnames';
-export function SchemaFrom({
- propKey,
- body,
- definitions,
- controls,
- onChange,
- value,
- env,
- api,
- popOverContainer,
- submitOnChange,
- node,
- manager,
- justify,
- ctx,
- pipeIn,
- pipeOut,
- readonly
-}: {
- propKey?: string;
- env: any;
- body?: Array;
- /**
- * @deprecated 用 body 代替
- */
- controls?: Array;
- definitions?: any;
- value: any;
- api?: any;
- onChange: (
- value: any,
- diff: any,
- filter: (schema: any, value: any, id: string, diff?: any) => any
- ) => void;
- popOverContainer?: () => HTMLElement | void;
- submitOnChange?: boolean;
- node?: EditorNodeType;
- manager: EditorManager;
- panelById?: string;
- justify?: boolean;
- ctx?: any;
- pipeIn?: (value: any) => any;
- pipeOut?: (value: any, oldValue: any) => any;
- readonly?: boolean;
-}) {
- const schema = React.useMemo(() => {
- let containerKey = 'body';
-
- if (Array.isArray(controls)) {
- body = controls;
- containerKey = 'controls';
- }
-
- body = Array.isArray(body) ? body.concat() : [];
-
- if (submitOnChange === false) {
- body.push({
- type: 'submit',
- label: '保存',
- level: 'primary',
- block: true,
- className: 'ae-Settings-actions'
- });
- }
- const schema = {
- key: propKey,
+export const SchemaForm = React.forwardRef(
+ (
+ {
+ propKey,
+ body,
definitions,
- [containerKey]: body,
- className: cx(
- 'config-form-content',
- 'ae-Settings-content',
- 'hoverShowScrollBar',
- submitOnChange === false ? 'with-actions' : ''
- ),
- wrapperComponent: 'div',
- type: 'form',
- title: '',
- mode: 'normal',
+ controls,
+ onChange,
+ value,
+ env,
api,
- wrapWithPanel: false,
- submitOnChange: submitOnChange !== false,
- messages: {
- validateFailed: ''
+ popOverContainer,
+ submitOnChange,
+ node,
+ manager,
+ justify,
+ ctx,
+ pipeIn,
+ pipeOut,
+ readonly,
+ disabled,
+ appendSubmitBtn,
+ ...rest
+ }: {
+ propKey?: string;
+ env: any;
+ body?: Array;
+ /**
+ * @deprecated 用 body 代替
+ */
+ controls?: Array;
+ definitions?: any;
+ value: any;
+ api?: any;
+ onChange: (
+ value: any,
+ diff: any,
+ filter: (schema: any, value: any, id: string, diff?: any) => any
+ ) => void;
+ popOverContainer?: () => HTMLElement | void;
+ submitOnChange?: boolean;
+ node?: EditorNodeType;
+ manager: EditorManager;
+ panelById?: string;
+ justify?: boolean;
+ ctx?: any;
+ pipeIn?: (value: any) => any;
+ pipeOut?: (value: any, oldValue: any) => any;
+ readonly?: boolean;
+ disabled?: boolean;
+ appendSubmitBtn?: boolean;
+ inheritData?: boolean;
+ },
+ ref: any
+ ) => {
+ const schema = React.useMemo(() => {
+ let containerKey = 'body';
+
+ if (Array.isArray(controls)) {
+ body = controls;
+ containerKey = 'controls';
}
- };
- if (justify) {
- schema.mode = 'horizontal';
- schema.horizontal = {
- left: 4,
- right: 8,
- justify: true
- };
- }
- return schema;
- }, [body, controls, submitOnChange]);
+ body = Array.isArray(body) ? body.concat() : [];
- const themeConfig = React.useMemo(() => getThemeConfig(), []);
- const submitSubscribers = React.useRef>([]);
- const subscribeSubmit = React.useCallback(
- (
- fn: (schema: any, value: any, id: string, diff?: any) => any,
- once = false
- ) => {
- let raw = fn;
- const unsubscribe = () => {
- submitSubscribers.current = submitSubscribers.current.filter(
- item => ((item as any).__raw ?? item) !== raw
- );
+ if (submitOnChange === false && appendSubmitBtn !== false) {
+ body.push({
+ type: 'submit',
+ label: '保存',
+ level: 'primary',
+ block: true,
+ className: 'ae-Settings-actions'
+ });
+ }
+ const schema: any = {
+ key: propKey,
+ mode: 'normal',
+ title: '',
+ wrapWithPanel: false,
+ messages: {
+ validateFailed: ''
+ },
+ ...pick(rest, [
+ 'mode',
+ 'title',
+ 'wrapWithPanel',
+ 'messages',
+ 'horizontal',
+ 'inheritData'
+ ]),
+ $schema: undefined,
+ definitions,
+ [containerKey]: body,
+ className: cx(
+ 'config-form-content',
+ 'ae-Settings-content',
+ 'hoverShowScrollBar',
+ submitOnChange === false ? 'with-actions' : ''
+ ),
+ wrapperComponent: 'div',
+ name: 'schemaform',
+ type: 'form',
+ api,
+ submitOnChange: submitOnChange !== false
};
- if (once) {
- fn = (schema: any, value: any, id: string, diff?: any) => {
- const ret = raw(schema, value, id, diff);
- unsubscribe();
- return ret;
+ if (justify) {
+ schema.mode = 'horizontal';
+ schema.horizontal = {
+ left: 4,
+ right: 8,
+ justify: true
};
- (fn as any).__raw = raw;
}
- submitSubscribers.current.push(fn);
- return unsubscribe;
- },
- []
- );
+ return schema;
+ }, [body, controls, submitOnChange]);
+
+ const themeConfig = React.useMemo(() => getThemeConfig(), []);
+ const submitSubscribers = React.useRef>([]);
+ const subscribeSubmit = React.useCallback(
+ (
+ fn: (schema: any, value: any, id: string, diff?: any) => any,
+ once = false
+ ) => {
+ let raw = fn;
+ const unsubscribe = () => {
+ submitSubscribers.current = submitSubscribers.current.filter(
+ item => ((item as any).__raw ?? item) !== raw
+ );
+ };
- const [init, setInit] = React.useState(true);
+ if (once) {
+ fn = (schema: any, value: any, id: string, diff?: any) => {
+ const ret = raw(schema, value, id, diff);
+ unsubscribe();
+ return ret;
+ };
+ (fn as any).__raw = raw;
+ }
+ submitSubscribers.current.push(fn);
+ return unsubscribe;
+ },
+ []
+ );
- const data = React.useMemo(() => {
- value = value || {};
- const finalValue = pipeIn ? pipeIn(value) : value;
+ const [init, setInit] = React.useState(true);
- return createObjectFromChain([
- ctx,
- themeConfig,
- ...extractObjectChain(finalValue)
- ]);
- }, [value, themeConfig, ctx]);
+ const data = React.useMemo(() => {
+ value = value || {};
+ const finalValue = pipeIn ? pipeIn(value) : value;
- return render(
- schema,
- {
- onFinished: async (newValue: any, action: any, store: IFormStore) => {
- newValue = pipeOut ? await pipeOut(newValue, value) : newValue;
- const diffValue = diff(value, newValue);
- // 没有变化时不触发onChange
- if (!diffValue) {
- return;
- }
+ return createObjectFromChain([
+ ctx,
+ themeConfig,
+ ...extractObjectChain(finalValue)
+ ]);
+ }, [value, themeConfig, ctx]);
+
+ const scopedRef = React.useRef(null);
+ const scopedRefSetter = React.useCallback((ref: any) => {
+ scopedRef.current = ref;
+ }, []);
- if (readonly && !init) {
- toast.error('不支持修改');
- store.setPristine(value);
- store.reset();
- return;
+ React.useImperativeHandle(ref, () => {
+ return {
+ submit: () => {
+ const form = scopedRef.current?.getComponentByName('schemaform');
+ if (!form) {
+ return false;
+ }
+ return form.doAction!({type: 'submit'}, form.props.data, true);
+ },
+ flush: () => {
+ const form = scopedRef.current?.getComponentByName('schemaform');
+ if (!form) {
+ return false;
+ }
+ return form.flush();
}
- setInit(false);
- onChange(newValue, diffValue, (schema, value, id, diff) => {
- return submitSubscribers.current.reduce((schema, fn) => {
- return fn(schema, value, id, diff);
- }, schema);
- });
+ };
+ });
+
+ return render(
+ schema,
+ {
+ onFinished: async (newValue: any, action: any, store: IFormStore) => {
+ newValue = pipeOut ? await pipeOut(newValue, value) : newValue;
+ const diffValue = diff(value, newValue);
+ // 没有变化时不触发onChange
+ if (!diffValue) {
+ return;
+ }
+
+ if (readonly && !init) {
+ toast.error('不支持修改');
+ store.setPristine(value);
+ store.reset();
+ return;
+ }
+ setInit(false);
+ onChange(newValue, diffValue, (schema, value, id, diff) => {
+ return submitSubscribers.current.reduce((schema, fn) => {
+ return fn(schema, value, id, diff);
+ }, schema);
+ });
+ },
+ disabled,
+ data: data,
+ node: node,
+ manager: manager,
+ popOverContainer,
+ subscribeSchemaSubmit: subscribeSubmit,
+ scopeRef: scopedRefSetter
},
- data: data,
- node: node,
- manager: manager,
- popOverContainer,
- subscribeSchemaSubmit: subscribeSubmit
- },
- {
- ...omit(env, 'replaceText')
- // theme: 'cxd' // 右侧属性配置面板固定使用cxd主题展示
- }
- );
-}
+ {
+ ...omit(env, 'replaceText')
+ // theme: 'cxd' // 右侧属性配置面板固定使用cxd主题展示
+ }
+ );
+ }
+);
diff --git a/packages/amis-editor-core/src/component/factory.tsx b/packages/amis-editor-core/src/component/factory.tsx
index e732897b84a..51642ad4914 100644
--- a/packages/amis-editor-core/src/component/factory.tsx
+++ b/packages/amis-editor-core/src/component/factory.tsx
@@ -19,7 +19,7 @@ import type {Schema} from 'amis';
import type {DataScope} from 'amis-core';
import type {RendererConfig} from 'amis-core';
import type {SchemaCollection} from 'amis';
-import {SchemaFrom} from './base/SchemaForm';
+import {SchemaForm} from './base/SchemaForm';
import memoize from 'lodash/memoize';
import {FormConfigWrapper} from './FormConfigWrapper';
@@ -296,7 +296,7 @@ export function makeSchemaFormRender(
const controls = filterBody(schema.controls);
return (
- store.globalVariables,
+ variables => {
+ const id = 'global-variables-schema';
+ const scope = this.dataSchema.root;
+ const globalSchema: any = {
+ type: 'object',
+ title: '全局变量',
+ properties: {}
+ };
+
+ variables.forEach(variable => {
+ globalSchema.properties[variable.key] = {
+ type: 'string',
+ title: variable.label || variable.key,
+ description: variable.description,
+ ...variable.valueSchema
+ };
+ });
+
+ const jsonschema: any = {
+ $id: id,
+ type: 'object',
+ properties: {
+ global: globalSchema
+ }
+ };
+ scope.removeSchema(jsonschema.$id);
+ scope.addSchema(jsonschema);
+ }
)
);
}
@@ -2278,6 +2314,69 @@ export class EditorManager {
}
}
+ /**
+ * 初始化全局变量
+ */
+ async initGlobalVariables() {
+ let variables: Array = [];
+ const context: GlobalVariablesEventContext = {
+ data: variables
+ };
+
+ // 从插件中获取全局变量
+ const event = this.trigger('global-variable-init', context);
+ if (event.pending) {
+ await event.pending;
+ }
+ this.store.setGlobalVariables(event.data);
+ }
+
+ /**
+ * 获取全局变量详情
+ */
+ async getGlobalVariableDetail(variable: Partial) {
+ const context: GlobalVariableEventContext = {
+ data: variable!
+ };
+
+ const event = this.trigger('global-variable-detail', context);
+ if (event.pending) {
+ await event.pending;
+ }
+ return event.data;
+ }
+
+ /**
+ * 保存全局变量,包括新增保存和编辑保存
+ */
+ async saveGlobalVariable(variable: Partial) {
+ const context: GlobalVariableEventContext = {
+ data: variable!
+ };
+
+ const event = this.trigger('global-variable-save', context);
+ if (event.pending) {
+ await event.pending;
+ }
+ return event.data;
+ }
+
+ /**
+ * 删除全局变量
+ */
+ async deleteGlobalVariable(variable: Partial) {
+ const context: GlobalVariableEventContext = {
+ data: variable!
+ };
+
+ const event = this.trigger('global-variable-delete', context);
+ if (event.pending) {
+ await event.pending;
+ }
+
+ return event.data;
+ }
+
beforeDispatchEvent(
originHook: any,
e: any,
diff --git a/packages/amis-editor-core/src/plugin.ts b/packages/amis-editor-core/src/plugin.ts
index 3d9c0447f7a..b8862ea0fed 100644
--- a/packages/amis-editor-core/src/plugin.ts
+++ b/packages/amis-editor-core/src/plugin.ts
@@ -14,7 +14,7 @@ import React from 'react';
import {DiffChange} from './util';
import find from 'lodash/find';
import {RAW_TYPE_MAP} from './util';
-import type {RendererConfig, Schema} from 'amis-core';
+import type {GlobalVariableItem, RendererConfig, Schema} from 'amis-core';
import type {MenuDivider, MenuItem} from 'amis-ui/lib/components/ContextMenu';
import type {BaseSchema, SchemaCollection} from 'amis';
import type {AsyncLayerOptions} from './component/AsyncLayer';
@@ -612,6 +612,14 @@ export interface ResizeMoveEventContext extends EventContext {
store: EditorStoreType;
}
+export interface GlobalVariablesEventContext extends EventContext {
+ data: Array;
+}
+
+export interface GlobalVariableEventContext extends EventContext {
+ data: Partial;
+}
+
export interface AfterBuildPanelBody extends EventContext {
data: SchemaCollection;
plugin: BasePlugin;
@@ -772,6 +780,24 @@ export interface PluginEventListener {
}
>
) => void;
+
+ // 外部可以接管全局变量的增删改查
+ // 全局变量列表获取
+ onGlobalVariableInit?: (
+ event: PluginEvent
+ ) => void;
+ // 全局变量详情信息
+ onGlobalVariableDetail?: (
+ event: PluginEvent
+ ) => void;
+ // 全局变量保存
+ onGlobalVariableSave?: (
+ event: PluginEvent
+ ) => void;
+ // 全局变量删除
+ onGlobalVariableDelete?: (
+ event: PluginEvent
+ ) => void;
}
/**
diff --git a/packages/amis-editor-core/src/store/editor.ts b/packages/amis-editor-core/src/store/editor.ts
index 0cd5af54f42..14d3a1f87ac 100644
--- a/packages/amis-editor-core/src/store/editor.ts
+++ b/packages/amis-editor-core/src/store/editor.ts
@@ -6,7 +6,8 @@ import {
eachTree,
extendObject,
createObject,
- extractObjectChain
+ extractObjectChain,
+ GlobalVariableItem
} from 'amis-core';
import {cast, getEnv, Instance, types} from 'mobx-state-tree';
import {
@@ -268,7 +269,17 @@ export const MainStore = types
/** 应用多语言状态,用于其它组件进行订阅 */
appLocaleState: types.optional(types.number, 0),
/** 全局广播事件 */
- globalEvents: types.optional(types.frozen>(), [])
+ globalEvents: types.optional(types.frozen>(), []),
+
+ /** 全局变量 */
+ globalVariables: types.optional(
+ types.frozen>(),
+ []
+ )
+ // types.optional(
+ // types.array(types.frozen()),
+ // []
+ // )
})
.views(self => {
return {
@@ -2355,6 +2366,12 @@ export const MainStore = types
self.globalEvents = events;
},
+ setGlobalVariables(
+ variables: Array
+ ) {
+ self.globalVariables = variables;
+ },
+
beforeDestroy() {
lazyUpdateTargetName.cancel();
}
diff --git a/packages/amis-editor-core/src/variable.ts b/packages/amis-editor-core/src/variable.ts
index f2b5fac0805..4da7dc87798 100644
--- a/packages/amis-editor-core/src/variable.ts
+++ b/packages/amis-editor-core/src/variable.ts
@@ -270,4 +270,25 @@ export class VariableManager {
? node[labelField ?? 'label'] ?? node[valueField ?? 'value'] ?? ''
: '';
}
+
+ /**
+ * 获取全局变量树形结构
+ * @returns
+ */
+ getGlobalVariablesOptions() {
+ let options: Option[] = [];
+
+ const rootScope = this.dataSchema?.root;
+ if (rootScope) {
+ options = rootScope
+ .getDataPropsAsOptions()
+ .filter((item: any) => ['global'].includes(item.value));
+ }
+ eachTree(options, item => {
+ if (item.type === 'array') {
+ delete item.children;
+ }
+ });
+ return options;
+ }
}
diff --git a/packages/amis-editor/examples/Editor.tsx b/packages/amis-editor/examples/Editor.tsx
index e0bf4cb518b..683acc14691 100644
--- a/packages/amis-editor/examples/Editor.tsx
+++ b/packages/amis-editor/examples/Editor.tsx
@@ -1,6 +1,14 @@
/* eslint-disable */
import * as React from 'react';
-import {Editor, ShortcutKey, BasePlugin, setThemeConfig} from '../src/index';
+import {
+ Editor,
+ ShortcutKey,
+ BasePlugin,
+ setThemeConfig,
+ PluginEvent,
+ GlobalVariableEventContext,
+ GlobalVariablesEventContext
+} from '../src/index';
import {Select, Renderer, uuid, Button} from 'amis';
import {currentLocale} from 'i18n-runtime';
import {Portal} from 'react-overlays';
@@ -677,6 +685,9 @@ export default class AMisSchemaEditor extends React.Component {
showOldEntry: false,
globalEventGetter: () => globalEvents
}}
+ onGlobalVariableInit={onGlobalVariableInit}
+ onGlobalVariableSave={onGlobalVariableSave}
+ onGlobalVariableDelete={onGlobalVariableDelete}
amisEnv={
{
variable: {
@@ -781,3 +792,62 @@ export default class AMisSchemaEditor extends React.Component {
);
}
}
+
+// 通过 localstorage 存储全局变量
+// 实际场景肯定是后端存储到数据库里面
+// 可以参考这个利用这三个事件来实现全局变量的增删改查
+function getGlobalVariablesFromStorage(): Array {
+ const key = 'amis-editor-example-global-variable';
+ let globalVariables = localStorage.getItem(key);
+ let variables: Array = [];
+
+ if (globalVariables) {
+ variables = JSON.parse(globalVariables);
+ }
+
+ return variables;
+}
+
+function saveGlobalVariablesToStorage(variables: Array) {
+ const key = 'amis-editor-example-global-variable';
+ localStorage.setItem(key, JSON.stringify(variables));
+}
+
+function onGlobalVariableInit(event: PluginEvent) {
+ event.setData(getGlobalVariablesFromStorage() || []);
+}
+
+function onGlobalVariableSave(event: PluginEvent) {
+ const item = event.data;
+ const variables = getGlobalVariablesFromStorage();
+ const idx = item.id
+ ? variables.findIndex((it: any) => it.id === item.id)
+ : -1;
+
+ if (idx === -1) {
+ item.id = uuid();
+ variables.push(item);
+ } else {
+ variables[idx] = item;
+ }
+
+ saveGlobalVariablesToStorage(variables);
+}
+
+function onGlobalVariableDelete(
+ event: PluginEvent
+) {
+ const item = event.data;
+ const variables = getGlobalVariablesFromStorage();
+ const idx = item.id
+ ? variables.findIndex((it: any) => it.id === item.id)
+ : -1;
+
+ if (idx === -1) {
+ return;
+ } else {
+ variables.splice(idx, 1);
+ }
+
+ saveGlobalVariablesToStorage(variables);
+}
diff --git a/packages/amis-editor/src/icons/index.tsx b/packages/amis-editor/src/icons/index.tsx
index 1b2ebb586de..4f2e79bcebc 100644
--- a/packages/amis-editor/src/icons/index.tsx
+++ b/packages/amis-editor/src/icons/index.tsx
@@ -160,6 +160,7 @@ import layout_fixed_top from './layout/layout-fixed-top.svg';
import inputAddFx from './other/+fx.svg';
import inputFx from './other/fx.svg';
import modalSetting from './other/modal-setting.svg';
+import globalVar from './other/global-var.svg';
// 属性配置面板/显示类型
import block from './display/block.svg';
@@ -317,6 +318,7 @@ registerIcon('property-sheet-plugin', propertySheet);
registerIcon('tooltip-plugin', tooltip);
registerIcon('divider-plugin', divider);
registerIcon('modal-setting', modalSetting);
+registerIcon('global-var', globalVar);
// 常见布局组件 icon x 13
registerIcon('layout-absolute-plugin', layout_absolute);
diff --git a/packages/amis-editor/src/icons/other/global-var.svg b/packages/amis-editor/src/icons/other/global-var.svg
new file mode 100644
index 00000000000..4b65b082b22
--- /dev/null
+++ b/packages/amis-editor/src/icons/other/global-var.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/amis-editor/src/index.tsx b/packages/amis-editor/src/index.tsx
index bd761d9ac8a..07981a39a2e 100644
--- a/packages/amis-editor/src/index.tsx
+++ b/packages/amis-editor/src/index.tsx
@@ -7,6 +7,7 @@ export * from './plugin';
export * from './validator';
export * from './renderer/event-control/actionsPanelManager';
export * from './renderer/event-control/helper';
+export * from './renderer/global-var-control/index';
import './renderer/OptionControl';
import './renderer/ValueFormatControl';
diff --git a/packages/amis-editor/src/plugin/GlobalVar.tsx b/packages/amis-editor/src/plugin/GlobalVar.tsx
new file mode 100644
index 00000000000..9e408c7522f
--- /dev/null
+++ b/packages/amis-editor/src/plugin/GlobalVar.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import {Icon} from 'amis';
+import {
+ BuildPanelEventContext,
+ BasePlugin,
+ BasicPanelItem,
+ registerEditorPlugin
+} from 'amis-editor-core';
+import {GlobalVarManagerPanel} from '../renderer/global-var-control/GlobalVarManagerPanel';
+
+/**
+ * 添加源码编辑功能
+ */
+export class GlobalVarPlugin extends BasePlugin {
+ onInit() {
+ this.manager.initGlobalVariables();
+ }
+
+ buildEditorPanel(
+ context: BuildPanelEventContext,
+ panels: Array
+ ) {
+ panels.push({
+ key: 'global-var',
+ icon: '', // 'fa fa-code',
+ title: (
+
+
+
+ ),
+ position: 'left',
+ component: GlobalVarManagerPanel
+ });
+ }
+}
+
+registerEditorPlugin(GlobalVarPlugin);
diff --git a/packages/amis-editor/src/plugin/index.ts b/packages/amis-editor/src/plugin/index.ts
index 368d4e8607b..9faed0ffc1c 100644
--- a/packages/amis-editor/src/plugin/index.ts
+++ b/packages/amis-editor/src/plugin/index.ts
@@ -160,3 +160,5 @@ export * from './Reset';
export * from './Submit';
export * from './Wrapper';
export * from './ColumnToggler';
+
+export * from './GlobalVar';
diff --git a/packages/amis-editor/src/renderer/event-control/actionsPanelPlugins/componentActionsPanel/setValue.tsx b/packages/amis-editor/src/renderer/event-control/actionsPanelPlugins/componentActionsPanel/setValue.tsx
index 8fd72d3c244..198f50f4416 100644
--- a/packages/amis-editor/src/renderer/event-control/actionsPanelPlugins/componentActionsPanel/setValue.tsx
+++ b/packages/amis-editor/src/renderer/event-control/actionsPanelPlugins/componentActionsPanel/setValue.tsx
@@ -147,6 +147,8 @@ registerActionPanel('setValue', {
const variableOptions = variableManager?.getVariableOptions() || [];
const pageVariableOptions =
variableManager?.getPageVariablesOptions() || [];
+ const globalVariableOptions =
+ variableManager?.getGlobalVariablesOptions() || [];
return [
{
children: ({render, data}: any) => {
@@ -158,10 +160,13 @@ registerActionPanel('setValue', {
mode: 'horizontal',
options: [
{label: '组件变量', value: 'cmpt'},
+ {label: '全局变量', value: 'global'},
{label: '页面参数', value: 'page'},
{label: '内存变量', value: 'app'}
],
- value: /^appVariables/.test(path) // 只需要初始化时更新value
+ value: /^global/.test(path) // 只需要初始化时更新value
+ ? 'global'
+ : /^appVariables/.test(path) // 只需要初始化时更新value
? 'app'
: /^(__page|__query)/.test(path)
? 'page'
@@ -477,6 +482,40 @@ registerActionPanel('setValue', {
}
])
]
+ },
+ // 全局变量
+ {
+ type: 'container',
+ visibleOn: '__actionSubType === "global"',
+ body: [
+ getArgsWrapper([
+ {
+ type: 'wrapper',
+ body: [
+ getCustomNodeTreeSelectSchema({
+ options: globalVariableOptions,
+ rootLabel: '全局变量',
+ label: '全局变量',
+ horizontal: {
+ leftFixed: true
+ }
+ }),
+ getSchemaTpl('formulaControl', {
+ name: 'value',
+ label: '数据设置',
+ variables: '${variables}',
+ size: 'lg',
+ mode: 'horizontal',
+ required: true,
+ placeholder: '请输入变量值',
+ horizontal: {
+ leftFixed: true
+ }
+ })
+ ]
+ }
+ ])
+ ]
}
];
}
diff --git a/packages/amis-editor/src/renderer/global-var-control/GlobalVarBuiltinPanel.tsx b/packages/amis-editor/src/renderer/global-var-control/GlobalVarBuiltinPanel.tsx
new file mode 100644
index 00000000000..9c4ea5562aa
--- /dev/null
+++ b/packages/amis-editor/src/renderer/global-var-control/GlobalVarBuiltinPanel.tsx
@@ -0,0 +1,97 @@
+import {registerGlobalVarPanel} from './GlobalVarManagerPanel';
+import React from 'react';
+import {SchemaForm} from 'amis-editor-core';
+
+const basicControls: Array = [
+ {
+ type: 'input-text',
+ label: '变量名',
+ name: 'key',
+ required: true,
+ size: 'md',
+ validations: {
+ isVariableName: true
+ },
+ addOn: {
+ type: 'text',
+ label: 'global.',
+ position: 'left'
+ }
+ },
+
+ {
+ type: 'input-text',
+ label: '标题',
+ name: 'label',
+ size: 'md'
+ },
+
+ {
+ type: 'json-schema-editor',
+ name: 'valueSchema',
+ label: '值格式',
+ rootTypeMutable: true,
+ showRootInfo: true,
+ value: 'string'
+ },
+
+ {
+ type: 'input-text',
+ label: '默认值',
+ name: 'defaultValue',
+ size: 'md'
+ },
+
+ {
+ type: 'switch',
+ label: '客户端持久化',
+ name: 'storageOn',
+ trueValue: 'client',
+ falseValue: '',
+ description: '是否在客户端持久化存储,刷新页面后依然有效'
+ },
+
+ {
+ type: 'button-group-select',
+ label: '数据作用域',
+ visibleOn: '${storageOn}',
+ name: 'scope',
+ options: [
+ {
+ label: '页面共享',
+ value: 'page'
+ },
+ {
+ label: '全局共享',
+ value: 'app'
+ }
+ ]
+ },
+
+ {
+ type: 'textarea',
+ label: '描述',
+ name: 'description'
+ }
+];
+
+/**
+ * 注册基本变量设置面板
+ */
+registerGlobalVarPanel('builtin', {
+ title: '基础变量',
+ description: '系统内置的全局变量',
+ component: (props: any) => (
+
+ )
+});
diff --git a/packages/amis-editor/src/renderer/global-var-control/GlobalVarManagerPanel.tsx b/packages/amis-editor/src/renderer/global-var-control/GlobalVarManagerPanel.tsx
new file mode 100644
index 00000000000..ef1f7b3c739
--- /dev/null
+++ b/packages/amis-editor/src/renderer/global-var-control/GlobalVarManagerPanel.tsx
@@ -0,0 +1,347 @@
+import React from 'react';
+import {GlobalVariableItem, noop, guid} from 'amis-core';
+import {PanelProps, SchemaForm, EditorManager} from 'amis-editor-core';
+import {observer} from 'mobx-react';
+import {Alert2, Button, ConfirmBox, LazyComponent, Spinner} from 'amis-ui';
+import {confirm} from 'amis';
+import {Icon} from '../../icons/index';
+
+type PanelComponentProps = {
+ value: GlobalVariableItem;
+ onChange: (value: GlobalVariableItem) => void;
+};
+
+export interface GlobalVarItemInEditor extends Omit {
+ id: string | number;
+}
+
+/**
+ * 全局变量管理面板
+ */
+export interface globalVarPanel {
+ /**
+ * 变量类型,不通的变量类型配置面板不一样
+ */
+ type: string | 'builtin';
+
+ /**
+ * 变量类型标题
+ */
+ title: string;
+
+ /**
+ * 变量类型描述
+ */
+ description?: string;
+
+ renderBrief?: (value: GlobalVariableItem) => React.ReactNode;
+
+ /**
+ * 验证数据合法性
+ * @param value
+ * @returns
+ */
+ validate?: (
+ value: GlobalVariableItem
+ ) => string | void | Promise;
+
+ /**
+ * 变量保存前支持数据格式化
+ * @param value
+ * @returns
+ */
+ pipeOut?: (value: GlobalVariableItem) => GlobalVariableItem;
+
+ /**
+ * 配置面板
+ */
+ component?: React.ComponentType;
+ getComponent?: (
+ manger: EditorManager
+ ) => Promise>;
+}
+
+const globalVarPanels: Array = [];
+
+export function GlobalVarSubPanel(props: any) {
+ const type = props.data.type || 'builtin';
+ const panel = globalVarPanels.find(item => item.type === type);
+
+ if (!panel) {
+ return 未找到对应的变量类型配置面板;
+ }
+
+ const formRef = React.useRef(null);
+
+ // 父级表单提交的时候让子表单 flush
+ // 否则子表单的值不会及时同步到父级表单
+ React.useEffect(() => {
+ const removeHook = props.addHook?.(
+ () => formRef.current?.submit?.(),
+ 'flush'
+ );
+
+ return () => {
+ removeHook?.();
+ };
+ }, []);
+
+ return panel.component ? (
+
+ ) : (
+
+ );
+}
+
+export function registerGlobalVarPanel(
+ type: string,
+ panel: Omit
+) {
+ globalVarPanels.push({...panel, type});
+}
+
+export interface GlobalVarMangerProps extends PanelProps {}
+
+export const GlobalVarManger = observer((props: GlobalVarMangerProps) => {
+ const {store, manager} = props;
+ const [loading, setLoading] = React.useState(false);
+ const [variableItem, setVariableItem] =
+ React.useState | null>(null);
+ const [isOpened, setIsOpened] = React.useState(false);
+
+ const handleAddVariable = React.useCallback(async () => {
+ setIsOpened(true);
+ try {
+ setLoading(true);
+ const detail = await manager.getGlobalVariableDetail({});
+ setVariableItem(detail);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const handleVariableChange = React.useCallback((value: any) => {
+ // 啥也不干
+ // 不外部控制了,直接让内部控制
+ }, []);
+
+ const handleUpdate = React.useCallback((e: React.UIEvent) => {
+ const index = parseInt(
+ (e.target as HTMLElement)
+ .closest('.ae-GlobalVarItem')!
+ .getAttribute('data-index')!,
+ 10
+ );
+ const item = store.globalVariables[index];
+ setVariableItem({...item});
+ setIsOpened(true);
+ }, []);
+
+ const handleDelete = React.useCallback(async (e: React.UIEvent) => {
+ const index = parseInt(
+ (e.target as HTMLElement)
+ .closest('.ae-GlobalVarItem')!
+ .getAttribute('data-index')!,
+ 10
+ );
+ const item = store.globalVariables[index];
+
+ const confirmed = await confirm('确认删除该全局变量?');
+ if (!confirmed) {
+ return;
+ }
+
+ setLoading(true);
+ manager
+ .deleteGlobalVariable(item)
+ .then(() => {
+ const variables = store.globalVariables.concat();
+ variables.splice(index, 1);
+ store.setGlobalVariables(variables);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, []);
+
+ const handleModalConfirm = React.useCallback(async (value: any) => {
+ try {
+ setLoading(true);
+ const type = value.type || 'builtin';
+ const panel = globalVarPanels.find(item => item.type === type);
+ if (!panel) {
+ throw new Error('未找到对应的变量类型配置面板');
+ }
+
+ if (
+ store.globalVariables.some(
+ item => item.key === value.key && item.id !== value.id
+ )
+ ) {
+ throw new Error('变量名已存在');
+ }
+
+ await panel.validate?.(value);
+ value = {...value}; // make it immutable
+ value = panel.pipeOut?.(value) || value;
+ value = await manager.saveGlobalVariable(value);
+
+ if (!value.id) {
+ value.id = guid();
+ }
+
+ const variables = store.globalVariables.concat();
+ const idx = value.id
+ ? variables.findIndex(item => item.id === value.id)
+ : -1;
+ if (~idx) {
+ variables[idx] = value;
+ } else {
+ variables.push(value);
+ }
+ store.setGlobalVariables(variables);
+ setIsOpened(false);
+ } finally {
+ setLoading(false);
+ }
+
+ return value;
+ }, []);
+
+ const handleModalClose = React.useCallback(() => {
+ setIsOpened(false);
+ }, []);
+
+ // 初始化全局变量
+ React.useEffect(() => {
+ setLoading(true);
+ let mounted = true;
+ manager.initGlobalVariables().finally(() => {
+ mounted && setLoading(false);
+ });
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ const schema = React.useMemo(() => {
+ const body: any[] = [];
+
+ if (globalVarPanels.length > 1) {
+ body.push({
+ type: 'button-group-select',
+ name: 'type',
+ label: '变量类型',
+ value: 'builtin',
+ options: globalVarPanels.map(item => ({
+ label: item.title,
+ value: item.type
+ }))
+ });
+ }
+
+ body.push({
+ component: GlobalVarSubPanel
+ });
+
+ return {
+ type: 'form',
+ mode: 'horizontal',
+ horizontal: {
+ left: 2
+ },
+ body: body,
+ submitOnChange: false,
+ appendSubmitBtn: false,
+ actions: []
+ };
+ }, []);
+
+ return (
+
+ {store.globalVariables.length ? (
+
+ ) : (
+
暂无
+ )}
+
+
+
+
+ {
+ (({bodyRef, loading, popOverContainer}: any) => (
+
+ )) as any
+ }
+
+
+
+
+ );
+});
+
+export function GlobalVarManagerPanel(props: any) {
+ return (
+
+ );
+}
diff --git a/packages/amis-editor/src/renderer/global-var-control/index.tsx b/packages/amis-editor/src/renderer/global-var-control/index.tsx
new file mode 100644
index 00000000000..d6b509cb387
--- /dev/null
+++ b/packages/amis-editor/src/renderer/global-var-control/index.tsx
@@ -0,0 +1,8 @@
+import {
+ GlobalVarManagerPanel,
+ registerGlobalVarPanel
+} from './GlobalVarManagerPanel';
+
+import './GlobalVarBuiltinPanel';
+
+export {GlobalVarManagerPanel, registerGlobalVarPanel};
diff --git a/packages/amis-ui/scss/components/_button.scss b/packages/amis-ui/scss/components/_button.scss
index ee7e4b3e7f8..f76e03b3dde 100644
--- a/packages/amis-ui/scss/components/_button.scss
+++ b/packages/amis-ui/scss/components/_button.scss
@@ -2,6 +2,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
+ text-align: center;
vertical-align: middle;
user-select: none;
background: transparent;
diff --git a/packages/amis/src/renderers/Tabs.tsx b/packages/amis/src/renderers/Tabs.tsx
index f9552bfcc97..7dccce07f72 100644
--- a/packages/amis/src/renderers/Tabs.tsx
+++ b/packages/amis/src/renderers/Tabs.tsx
@@ -6,7 +6,7 @@ import {
resolveEventData,
setThemeClassName
} from 'amis-core';
-import {ActionObject} from 'amis-core';
+import {ActionObject, isGlobalVarExpression} from 'amis-core';
import find from 'lodash/find';
import {
isVisible,
@@ -1115,7 +1115,23 @@ export default class Tabs extends React.Component {
}
}
@Renderer({
- type: 'tabs'
+ type: 'tabs',
+ onGlobalVarChanged(instance, schema, data): any {
+ if (isGlobalVarExpression(schema.source)) {
+ // tabs 要靠手动刷新了
+ const [newLocalTabs, isFromSource] = (instance as any).initTabArray(
+ (instance.props as any).tabs,
+ (instance.props as any).source,
+ data
+ );
+
+ instance.setState({
+ localTabs: newLocalTabs,
+ isFromSource
+ });
+ return false;
+ }
+ }
})
export class TabsRenderer extends Tabs {
static contextType = ScopedContext;