From 8add8034278cb1b6240003628b21dfc78acbefe1 Mon Sep 17 00:00:00 2001 From: qkiroc <30946345+qkiroc@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:27:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=9B=E5=85=A5=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=89=8D=E6=89=A7=E8=A1=8C=E5=8A=A8=E7=94=BB=20(#11342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 进入视图才执行动画 * 入场动画支持配置重复 * 优化 * bugfxi * 样式微调 --- .../EventAction/update-data/SetVariable.jsx | 116 ++-- examples/components/Example.jsx | 7 +- examples/components/SchemaRender.jsx | 9 + packages/amis-core/src/Root.tsx | 3 + packages/amis-core/src/RootRenderer.tsx | 8 +- packages/amis-core/src/SchemaRenderer.tsx | 115 ++-- packages/amis-core/src/actions/CmptAction.ts | 5 + .../amis-core/src/components/Animations.tsx | 158 ++++++ packages/amis-core/src/factory.tsx | 8 + packages/amis-core/src/globalVar.ts | 350 ++++++++++++ .../amis-core/src/globalVarClientHandler.ts | 84 +++ .../src/globalVarDefaultValueHandler.ts | 30 ++ packages/amis-core/src/index.tsx | 3 + packages/amis-core/src/renderers/Item.tsx | 17 +- packages/amis-core/src/store/root.ts | 510 +++++++++++++++++- packages/amis-core/src/utils/animations.ts | 4 + packages/amis-core/src/utils/helper.ts | 3 + .../scss/control/_event-action.scss | 3 - .../scss/control/_global-var.scss | 63 +++ packages/amis-editor-core/scss/editor.scss | 3 + .../src/component/IFramePreview.tsx | 1 + .../src/component/Preview.tsx | 1 + .../src/component/base/SchemaForm.tsx | 364 +++++++------ .../src/component/factory.tsx | 4 +- packages/amis-editor-core/src/index.ts | 5 +- packages/amis-editor-core/src/manager.ts | 103 +++- packages/amis-editor-core/src/plugin.ts | 28 +- packages/amis-editor-core/src/store/editor.ts | 21 +- packages/amis-editor-core/src/variable.ts | 21 + packages/amis-editor/examples/Editor.tsx | 72 ++- packages/amis-editor/src/icons/index.tsx | 2 + .../src/icons/other/global-var.svg | 1 + packages/amis-editor/src/index.tsx | 1 + packages/amis-editor/src/plugin/GlobalVar.tsx | 40 ++ packages/amis-editor/src/plugin/index.ts | 2 + .../componentActionsPanel/setValue.tsx | 41 +- .../GlobalVarBuiltinPanel.tsx | 131 +++++ .../GlobalVarManagerPanel.tsx | 347 ++++++++++++ .../src/renderer/global-var-control/index.tsx | 8 + packages/amis-editor/src/tpl/style.tsx | 37 +- packages/amis-ui/scss/base/_typography.scss | 3 + packages/amis-ui/scss/components/_button.scss | 1 + packages/amis/src/renderers/Tabs.tsx | 20 +- 43 files changed, 2418 insertions(+), 335 deletions(-) create mode 100644 packages/amis-core/src/components/Animations.tsx create mode 100644 packages/amis-core/src/globalVar.ts create mode 100644 packages/amis-core/src/globalVarClientHandler.ts create mode 100644 packages/amis-core/src/globalVarDefaultValueHandler.ts create mode 100644 packages/amis-editor-core/scss/control/_global-var.scss create mode 100644 packages/amis-editor/src/icons/other/global-var.svg create mode 100644 packages/amis-editor/src/plugin/GlobalVar.tsx create mode 100644 packages/amis-editor/src/renderer/global-var-control/GlobalVarBuiltinPanel.tsx create mode 100644 packages/amis-editor/src/renderer/global-var-control/GlobalVarManagerPanel.tsx create mode 100644 packages/amis-editor/src/renderer/global-var-control/index.tsx diff --git a/examples/components/EventAction/update-data/SetVariable.jsx b/examples/components/EventAction/update-data/SetVariable.jsx index 55c3f9bd6ed..7045c397723 100644 --- a/examples/components/EventAction/update-data/SetVariable.jsx +++ b/examples/components/EventAction/update-data/SetVariable.jsx @@ -5,22 +5,24 @@ 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' } -}; +]; export default { /** schema配置 */ @@ -30,7 +32,7 @@ export default { body: [ { type: 'tpl', - tpl: '变量的命名空间通过环境变量设置为了appVariables, 可以通过\\${appVariables.xxx}来取值' + tpl: '变量的命名空间通过环境变量设置为了global, 可以通过\\${global.xxx}来取值' }, { type: 'container', @@ -43,12 +45,12 @@ export default { body: [ { type: 'tpl', - tpl: '

数据域appVariables

' + tpl: '

数据域global

' }, { type: 'json', id: 'u:44521540e64c', - source: '${appVariables}', + source: '${global}', levelExpand: 10 }, { @@ -59,7 +61,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 +84,7 @@ export default { actions: [ { args: { - path: 'appVariables.ProductName', + path: 'global.ProductName', value: '${event.data.value}' }, actionType: 'setValue' @@ -95,8 +97,31 @@ 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' + } + ] + } + } } ], id: 'u:dc2580fa447a' @@ -109,7 +134,7 @@ export default { actions: [ { args: { - path: 'appVariables.ProductName', + path: 'global.ProductName', value: '${event.data.ProductName}' }, actionType: 'setValue' @@ -119,51 +144,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 e4672df6667..c8b8cddba82 100644 --- a/packages/amis-core/src/SchemaRenderer.tsx +++ b/packages/amis-core/src/SchemaRenderer.tsx @@ -39,9 +39,12 @@ import {buildStyle} from './utils/style'; import {isExpression} from './utils/formula'; import {StatusScopedProps} from './StatusScoped'; import {evalExpression, filter} from './utils/tpl'; +import Animations from './components/Animations'; import {CSSTransition} from 'react-transition-group'; import {createAnimationStyle} from './utils/animations'; import styleManager from './StyleManager'; +import {observeGlobalVars} from './globalVar'; +import {cloneObject} from './utils/object'; interface SchemaRendererProps extends Partial>, @@ -103,15 +106,7 @@ export class SchemaRenderer extends React.Component { schema: any; path: string; - animationTimeout: { - enter?: number; - exit?: number; - } = {}; - animationClassNames: { - appear?: string; - enter?: string; - exit?: string; - } = {}; + tmpData: any; toDispose: Array<() => any> = []; unbindEvent: (() => void) | undefined = undefined; @@ -120,39 +115,22 @@ export class SchemaRenderer extends React.Component { constructor(props: SchemaRendererProps) { super(props); - const animations = props?.schema?.animations; - if (animations) { - let id = props?.schema.id; - id = formateId(id); - if (animations.enter) { - this.animationTimeout.enter = - ((animations.enter.duration || 1) + (animations.enter.delay || 0)) * - 1000; - this.animationClassNames.enter = `${animations.enter.type}-${id}-enter`; - this.animationClassNames.appear = this.animationClassNames.enter; - } - if (animations.exit) { - this.animationTimeout.exit = - ((animations.exit.duration || 1) + (animations.exit.delay || 0)) * - 1000; - this.animationClassNames.exit = `${animations.exit.type}-${id}-exit`; - } - } this.refFn = this.refFn.bind(this); this.renderChild = this.renderChild.bind(this); this.reRender = this.reRender.bind(this); this.resolveRenderer(this.props); this.dispatchEvent = this.dispatchEvent.bind(this); - this.addAnimationAttention = this.addAnimationAttention.bind(this); - this.removeAnimationAttention = this.removeAnimationAttention.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] @@ -167,14 +145,10 @@ export class SchemaRenderer extends React.Component { () => this.forceUpdate() ) ); - } - componentDidMount(): void { - if (this.props.schema.animations) { - let {animations, id} = this.props.schema; - id = formateId(id); - createAnimationStyle(id, animations); - } + this.toDispose.push( + observeGlobalVars(schema, props.topStore, this.handleGlobalVarChange) + ); } componentWillUnmount() { @@ -182,7 +156,6 @@ export class SchemaRenderer extends React.Component { this.toDispose = []; this.unbindEvent?.(); this.unbindGlobalEvent?.(); - this.removeAnimationStyle(); } // 限制:只有 schema 除外的 props 变化,或者 schema 里面的某个成员值发生变化才更新。 @@ -213,12 +186,19 @@ export class SchemaRenderer extends React.Component { return false; } - removeAnimationStyle() { - if (this.props.schema.animations) { - let {id} = this.props.schema; - id = formateId(id); - styleManager.removeStyles(id); + 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 { @@ -357,7 +337,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 }); } @@ -367,25 +350,6 @@ export class SchemaRenderer extends React.Component { this.forceUpdate(); } - addAnimationAttention(node: HTMLElement) { - const {schema} = this.props || {}; - const {attention} = schema?.animations || {}; - if (attention) { - let {id} = schema; - id = formateId(id); - node.classList.add(`${attention.type}-${id}-attention`); - } - } - removeAnimationAttention(node: HTMLElement) { - const {schema} = this.props || {}; - const {attention} = schema?.animations || {}; - if (attention) { - let {id} = schema; - id = formateId(id); - node.classList.remove(`${attention.type}-${id}-attention`); - } - } - render(): JSX.Element | null { let { $path: _, @@ -536,7 +500,7 @@ export class SchemaRenderer extends React.Component { } = schema; const Component = renderer.component!; - let animationIn = true; + let animationShow = true; // 原来表单项的 visible: false 和 hidden: true 表单项的值和验证是有效的 // 而 visibleOn 和 hiddenOn 是无效的, @@ -550,7 +514,7 @@ export class SchemaRenderer extends React.Component { (schema.visible !== false && !schema.hidden)) ) { if (schema.animations) { - animationIn = false; + animationShow = false; } else { return null; } @@ -585,6 +549,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); @@ -629,17 +596,11 @@ export class SchemaRenderer extends React.Component { if (schema.animations) { component = ( - - {component} - + ); } 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/components/Animations.tsx b/packages/amis-core/src/components/Animations.tsx new file mode 100644 index 00000000000..27bcc7b0dca --- /dev/null +++ b/packages/amis-core/src/components/Animations.tsx @@ -0,0 +1,158 @@ +import React, {useEffect, useMemo, useState, useCallback} from 'react'; +import {CSSTransition} from 'react-transition-group'; +import {Schema} from '../types'; +import {formateId} from '../utils'; +import {createAnimationStyle} from '../utils/animations'; +import styleManager from '../StyleManager'; + +function Animations({ + schema, + component, + show +}: { + schema: Schema; + component: any; + show: boolean; +}) { + const {enter} = schema.animations || {}; + const [animationShow, setAnimationShow] = useState(!enter?.inView); + const [placeholderShow, setPlaceholderShow] = useState(!!enter?.inView); + + const id = useMemo(() => formateId(schema.id), []); + const observer = useMemo(newObserver, []); + const animationClassNames = useMemo(initAnimationClassNames, []); + const animationTimeout = useMemo(initAnimationTimeout, []); + + useEffect(() => { + createAnimationStyle(id, schema.animations!); + return () => { + if (schema.animations) { + styleManager.removeStyles(id); + } + observer.disconnect(); + }; + }, []); + + function newObserver() { + return new IntersectionObserver( + (entries, observer) => { + entries.forEach(entry => { + if ( + entry.target.getAttribute('data-role') === 'animation-placeholder' + ) { + if (entry.isIntersecting) { + setAnimationShow(true); + setPlaceholderShow(false); + observer.unobserve(entry.target); + } + } else { + if (!entry.isIntersecting) { + setAnimationShow(false); + observer.unobserve(entry.target); + } + } + }); + }, + { + root: null, + rootMargin: '0px', + threshold: 0.1 + } + ); + } + + function initAnimationClassNames() { + const animations = schema?.animations; + const animationClassNames = { + appear: '', + enter: '', + exit: '' + }; + if (animations) { + if (animations.enter) { + animationClassNames.enter = `${animations.enter.type}-${id}-enter`; + animationClassNames.appear = animationClassNames.enter; + } + if (animations.exit) { + animationClassNames.exit = `${animations.exit.type}-${id}-exit`; + } + } + return animationClassNames; + } + + function initAnimationTimeout() { + const animations = schema?.animations; + const animationTimeout = { + enter: 0, + exit: 0 + }; + if (animations) { + if (animations.enter) { + animationTimeout.enter = + ((animations.enter.duration || 1) + (animations.enter.delay || 0)) * + 1000; + } + if (animations.exit) { + animationTimeout.exit = + ((animations.exit.duration || 1) + (animations.exit.delay || 0)) * + 1000; + } + } + return animationTimeout; + } + + function refFn(ref: HTMLElement | null) { + if (ref) { + observer.observe(ref); + } + } + + const handleEntered = useCallback((node: HTMLElement) => { + const {attention, exit, enter} = schema.animations || {}; + if (attention) { + node.classList.add(`${attention.type}-${id}-attention`); + } + if (exit?.outView || enter?.repeat) { + observer.observe(node); + } + }, []); + + const handleExit = useCallback((node: HTMLElement) => { + const {attention} = schema.animations || {}; + if (attention) { + node.classList.remove(`${attention.type}-${id}-attention`); + } + }, []); + + const handleExited = useCallback(() => { + setPlaceholderShow(true); + }, []); + + return ( + <> + {!animationShow && show && placeholderShow && ( +
+ {component} +
+ )} + + {component} + + + ); +} + +export default Animations; 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..f1d0dbd736b 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,20 @@ 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.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..c9d0119b482 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) { + updateContext(context: GlobalVarContext) { // 因为 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-core/src/utils/animations.ts b/packages/amis-core/src/utils/animations.ts index 043d8f732e4..624e8cf1b66 100644 --- a/packages/amis-core/src/utils/animations.ts +++ b/packages/amis-core/src/utils/animations.ts @@ -5,6 +5,8 @@ export interface AnimationsProps { type: string; duration?: number; delay?: number; + repeat?: boolean; + inView?: boolean; }; attention?: { type: string; @@ -16,6 +18,8 @@ export interface AnimationsProps { type: string; duration?: number; delay?: number; + repeat?: boolean; + outView?: boolean; }; } diff --git a/packages/amis-core/src/utils/helper.ts b/packages/amis-core/src/utils/helper.ts index 38e5097ec5c..a6f30c67502 100644 --- a/packages/amis-core/src/utils/helper.ts +++ b/packages/amis-core/src/utils/helper.ts @@ -2437,6 +2437,9 @@ export function supportsMjs() { } export function formateId(id: string) { + if (!id) { + return guid(); + } // 将className非法字符替换为短横线 id = id.replace(/[^a-zA-Z0-9-]/g, '-'); // 将连续的-替换为单个- diff --git a/packages/amis-editor-core/scss/control/_event-action.scss b/packages/amis-editor-core/scss/control/_event-action.scss index d1e8466cf82..9588f8b6c2f 100644 --- a/packages/amis-editor-core/scss/control/_event-action.scss +++ b/packages/amis-editor-core/scss/control/_event-action.scss @@ -446,9 +446,6 @@ overflow-y: auto; height: 100%; padding: 0 8px 0 0; - .event-action-radio { - padding-top: 5px; - } .action-desc { margin-left: 16px; color: var(--Form-item-color); 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..f040090bfd6 --- /dev/null +++ b/packages/amis-editor/src/renderer/global-var-control/GlobalVarBuiltinPanel.tsx @@ -0,0 +1,131 @@ +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) => ( + + ) +}); + +// const advancedControls = [ +// { +// type: 'input-text', +// name: 'name', +// label: '变量名', +// required: true +// }, +// { +// type: 'input-text', +// name: 'value', +// label: '变量值', +// required: true +// } +// ]; +// registerGlobalVarPanel('advanced', { +// title: '高级变量', +// description: '', +// component: (props: any) => ( +// +// ), +// validate(value) { +// console.log(value); +// // throw new Error('不合法'); +// } +// }); 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..77f9cdbe0ae --- /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: '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 ? ( +
    + {store.globalVariables.map((item, index) => { + return ( +
  • +
    + {item.label || item.key} +
    +
    + + + +
    +
  • + ); + })} +
+ ) : ( +
暂无
+ )} + + + + + { + (({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-editor/src/tpl/style.tsx b/packages/amis-editor/src/tpl/style.tsx index d85c4ad966f..90765c89fa6 100644 --- a/packages/amis-editor/src/tpl/style.tsx +++ b/packages/amis-editor/src/tpl/style.tsx @@ -1,4 +1,9 @@ -import {setSchemaTpl, getSchemaTpl, defaultValue} from 'amis-editor-core'; +import { + setSchemaTpl, + getSchemaTpl, + defaultValue, + tipedLabel +} from 'amis-editor-core'; import {createAnimationStyle, formateId, type SchemaCollection} from 'amis'; import kebabCase from 'lodash/kebabCase'; import {styleManager} from 'amis-core'; @@ -1532,7 +1537,26 @@ setSchemaTpl('animation', () => { return { title: '动画', body: [ - ...animation('enter', '进入动画'), + ...animation('enter', '进入动画', [ + { + label: tipedLabel('可见时触发', '组件进入可见区域才触发进入动画'), + type: 'switch', + name: 'animations.enter.inView', + value: true, + onChange: (value: any, oldValue: any, obj: any, props: any) => { + if (value === false) { + props.setValueByName('animations.enter.repeat', false); + } + } + }, + { + label: tipedLabel('重复', '组件再次进入可见区域时重复播放动画'), + type: 'switch', + name: 'animations.enter.repeat', + visibleOn: 'animations.enter.inView', + value: false + } + ]), ...animation('attention', '强调动画', [ { label: '重复', @@ -1548,7 +1572,14 @@ setSchemaTpl('animation', () => { ] } ]), - ...animation('exit', '退出动画') + ...animation('exit', '退出动画', [ + { + label: tipedLabel('不可见时触发', '组件退出可见区域触发进入动画'), + type: 'switch', + name: 'animations.exit.outView', + value: true + } + ]) ] }; }); diff --git a/packages/amis-ui/scss/base/_typography.scss b/packages/amis-ui/scss/base/_typography.scss index 5f23380c65a..6099f885619 100644 --- a/packages/amis-ui/scss/base/_typography.scss +++ b/packages/amis-ui/scss/base/_typography.scss @@ -74,3 +74,6 @@ samp { width: 100%; height: 100%; } +.amis-animation-placeholder { + opacity: 0; +} 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;