<template>
<slot>
<span :class="['icon', { 'icon-bind': modelValue?.variable }]" @click="open">
<svg-button name="cloud-shell" placement="top" tips="变量绑定"></svg-button>
</span>
</slot>
<tiny-dialog-box
v-if="dialogShouldInitialize"
:visible="state.isVisible"
title="变量绑定"
width="48%"
:append-to-body="true"
class="meta-bind-variable-dialog-box"
@update:visible="state.isVisible = $event"
@close="cancel"
>
<div class="bind-dialog-container">
<div class="bind-dialog-container-header">
<tiny-alert
type="info"
description="你可以通过点击左侧区域变量列表绑定变量或处理函数,也可以在右边输入模式输入复杂的表达式。"
class="header-alert"
:closable="false"
></tiny-alert>
</div>
<div class="bind-dialog-content">
<div class="content-left">
<span class="content-left__title">变量列表</span>
<div class="list-wrap">
<ul class="content-left__list">
<li
v-for="item in state.variableList"
:key="item.id"
:class="{ 'content-left__list-item': true, active: item.id === state.active }"
@click="selectItem(item)"
>
{{ item.content }}
</li>
</ul>
<div class="item-content">
<tiny-search v-model="state.value" placeholder="搜索"></tiny-search>
<div class="item-content-list lowcode-scrollbar-thin">
<ul>
<li
v-for="(item, key) in state.variables"
v-show="key.includes(state.value)"
:key="key"
:class="{ 'item-selected': state.variableName === key }"
@click="variableClick(key, item)"
>
<div class="item-text" :title="state.bindPrefix + key">{{ `${state.bindPrefix}${key}` }}</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="content-right">
<div class="content-detail-header">
<div class="header-name">
<span class="content-right__title">变量</span>
</div>
</div>
<div class="content-wrap">
<div v-if="!state.isEditorEditMode" class="top">{{ state.variable }}</div>
<div v-else :class="['top', { 'poll-top': isDataSource }]">
<monaco-editor
ref="editor"
:value="state.variable"
:options="editorOptions"
@editorDidMount="editorDidMount"
@change="editorChange"
></monaco-editor>
<div v-if="isDataSource" class="datasource-poll-wrap">
<tiny-tooltip
placement="top"
content="定时更新开启后,页面运行时将会定期请求远程数据源,实现数据定时更新。"
effect="light"
><span>定时更新:</span></tiny-tooltip
>
<tiny-switch v-model="state.isPoll"></tiny-switch>
<div v-if="state.isPoll" class="datasource-poll-interval">
<span>更新时间:</span>
<tiny-input type="number" v-model="state.pollInterval"></tiny-input>
<span>ms</span>
</div>
</div>
</div>
<div class="bottom lowcode-scrollbar-thin">
<span class="bottom-title">用法</span>
<div class="bottom-demo">
<p>
你可以通过点击左侧区域绑定变量或处理函数,或者点击右边的铅笔按钮切换到输入模式,输入复杂的表达式。
</p>
<p>输入框内默认支持变量,写法和 JS 写法完全一致。</p>
<div>页面状态: this.state.xxx</div>
<div>字符串: "string"</div>
<div>数字: 123</div>
<div>布尔值: true / false</div>
<div>对象: { name: "张三" }</div>
<div>数组: ["1", "2"]</div>
<div>空值: null</div>
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="bind-dialog-footer">
<tiny-button type="danger" plain :disabled="modelValue?.type !== 'JSExpression'" @click="remove"
>移除绑定</tiny-button
>
<div class="right">
<tiny-button @click="cancel">取 消</tiny-button>
<tiny-button type="info" :disabled="confirmDisabled" @click="confirm">确 定</tiny-button>
</div>
</div>
</template>
</tiny-dialog-box>
</template>
<script lang="ts">
import { VueMonaco as MonacoEditor, SvgButton } from '@opentiny/tiny-engine-common'
import {
useCanvas,
useProperties,
useResource,
getMetaApi,
META_APP,
META_SERVICE
} from '@opentiny/tiny-engine-meta-register'
import { getCommentByKey } from '@opentiny/tiny-engine-common/js/comment'
import { generate, parse, traverse } from '@opentiny/tiny-engine-common/js/ast'
import { DEFAULT_LOOP_NAME } from '@opentiny/tiny-engine-common/js/constants'
import { constants } from '@opentiny/tiny-engine-utils'
import { Alert, Button, DialogBox, Input, Search, Switch, Tooltip } from '@opentiny/vue'
import { camelize, capitalize } from '@vue/shared'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import type { Component } from 'vue'
const { EXPRESSION_TYPE } = constants
const CONSTANTS = {
THIS: 'this.',
STATE: 'this.state.',
STORE: 'this.stores.',
PROPS: 'this.props.',
COLLECTION: 'Collection',
ITEM: 'item',
DATASOUCE: 'datasource',
DATASOUCEPREFIX: '数据源: ',
DATASOURCEMAP: 'this.dataSourceMap.',
INTERVALID: 'intervalId'
}
const getJsSlot = () => {
const { getCurrent } = useCanvas().canvasApi.value || {}
if (!getCurrent) {
return [false, {}]
}
const { getNodeWithParentById } = useCanvas()
const jsSlot = getNodeWithParentById(getCurrent()?.parent?.id, true)?.parent
return [jsSlot?.type === 'JSSlot', jsSlot]
}
const getJsSlotParams = () => {
const [isJsSlot, jsSlot] = getJsSlot()
return isJsSlot ? jsSlot?.params || [] : []
}
export default {
name: 'VariableConfigurator',
components: {
MonacoEditor,
TinyDialogBox: DialogBox,
TinyButton: Button as Component,
TinySearch: Search,
TinySwitch: Switch,
TinyInput: Input,
TinyTooltip: Tooltip as Component,
SvgButton,
TinyAlert: Alert
},
inheritAttrs: false,
props: {
name: {
type: String,
default: ''
},
modelValue: {
type: [String, Number, Boolean, Array, Object, Date],
default: ''
},
lazyLoad: {
type: Boolean,
default: true
}
},
setup(props, { emit }) {
const editor = ref(null)
let oldValue = ''
const list = [
{ id: 'state', content: 'State 属性' },
{ id: 'store', content: '应用状态' },
{ id: 'function', content: '自定义处理函数' },
{ id: 'utils', content: '工具类' },
{ id: 'bridge', content: '桥接源' },
{ id: 'datasource', content: '数据源' }
]
const state = reactive({
isBlock: computed(() => useCanvas().isBlock()),
variables: {},
isVisible: false,
value: '',
active: 'state',
variableList: computed(() => {
const extendedVars = []
const [isInJsSlot] = getJsSlot()
if (state.isBlock) {
extendedVars.push({ id: 'props', content: 'props' })
}
if (state.loopData) {
extendedVars.push({ id: 'loop', content: '循环变量' })
}
if (isInJsSlot) {
extendedVars.push({ id: 'slotScope', content: '暴露给插槽使用的变量' })
}
return [...list, ...extendedVars]
}),
variable: '',
variableContent: null,
variableName: '',
isEditorEditMode: true,
dataSouce: null,
mock: props.modelValue?.value || props.modelValue,
bindPrefix: '',
loopData: null,
loopArgs: '',
isPoll: false,
pollInterval: 5000
})
const isDataSource = computed(() => state.active === CONSTANTS.DATASOUCE)
const confirmDisabled = computed(() => !state.variable?.trim())
watch(
() => state.isVisible,
(value) => {
if (value) {
oldValue = state.variable
state.loopData = useProperties().getSchema()?.loop
}
}
)
const bindKey = computed(() => props.modelValue?.value?.replace?.('this.state.', '') || '')
const editorOptions = {
language: 'javascript',
lineNumbers: false,
minimap: {
enabled: false
},
tabSize: 2,
insertSpaces: true,
formatOnPaste: true,
formatOnType: true,
autoIndent: 'full',
newLineCharacter: '\n',
convertTabsToSpaces: true,
trimAutoWhitespace: true,
wordWrap: 'on',
wordWrapColumn: 120,
wordWrapMinChars: 10,
wordWrapStrategy: 'advanced'
}
const editorDidMount = () => {
if (!editor.value) {
return
}
const diagnosticsOptions = editor.value
.getMonaco()
.languages.typescript.javascriptDefaults.getDiagnosticsOptions()
editor.value.getMonaco().languages.typescript.javascriptDefaults.setDiagnosticsOptions({
...diagnosticsOptions,
noSyntaxValidation: true,
noSemanticValidation: true
})
}
const editorChange = (value) => {
state.variable = value
}
const removeInterval = (start, end, intervalId, pageSchema) => {
const unmountedFn = pageSchema.lifeCycles?.onUnmounted?.value
const fetchBody = `
/** ${start} */
clearInterval(state.${intervalId});
/** ${end} */`
if (!unmountedFn) {
pageSchema.lifeCycles = pageSchema.lifeCycles || {}
pageSchema.lifeCycles.onUnmounted = {
type: 'JSFunction',
value: `function onUnmounted() {${fetchBody}}`
}
} else {
if (!unmountedFn.includes(`${intervalId}`)) {
pageSchema.lifeCycles.onUnmounted.value = unmountedFn.trim().replace(/\}$/, fetchBody + '}')
}
}
}
const genRemoteMethodToLifeSetup = (variableName, sourceRef, pageSchema) => {
if (sourceRef?.data?.data) {
const setupFn = pageSchema.lifeCycles?.setup?.value
const { start, end } = getCommentByKey(variableName)
const intervalId = `${CONSTANTS.INTERVALID}${capitalize(camelize(sourceRef.name))}`
const isPoll = state.isPoll && state.pollInterval !== undefined
let fetchBodyFn = `${CONSTANTS.DATASOURCEMAP}${sourceRef.name}.load().then(res => {
state.${variableName} = res?.data?.items || res?.data || res
})`
if (isPoll) {
fetchBodyFn = `state.${intervalId} = setInterval(() => {${CONSTANTS.DATASOURCEMAP}${sourceRef.name}.load().then(res => {
state.${variableName} = res?.data?.items || res?.data || res
})}, ${state.pollInterval})`
}
const fetchBody = `
/** ${start} */
${fetchBodyFn};
/** ${end} */`
if (!setupFn) {
pageSchema.lifeCycles = pageSchema.lifeCycles || {}
pageSchema.lifeCycles.setup = {
type: 'JSFunction',
value: `function setup({ props, state, watch, onMounted }) {${fetchBody}}`
}
} else {
if (!setupFn.includes(`${CONSTANTS.DATASOURCEMAP}${sourceRef.name}`)) {
pageSchema.lifeCycles.setup.value = setupFn.trim().replace(/\}$/, fetchBody + '}')
} else {
const ast = parse(setupFn)
traverse(ast, {
ExpressionStatement(path) {
if (path.toString().includes(sourceRef.name)) {
path.replaceWithSourceString(fetchBodyFn)
path.stop()
}
}
})
pageSchema.lifeCycles.setup.value = generate(ast).code
}
}
if (isPoll) {
removeInterval(start, end, intervalId, pageSchema)
}
}
}
const variableClick = (key, item) => {
if (state.bindPrefix === CONSTANTS.DATASOUCEPREFIX) {
const stateName = `${CONSTANTS.DATASOUCE}${capitalize(camelize(key))}`
const staticData = item?.data?.data || []
state.dataSouce = item
state.variable = `${CONSTANTS.STATE}${stateName}`
editor.value.getEditor().setValue(state.variable)
state.variableName = key
state.variableContent = staticData
} else {
state.variable = !state.bindPrefix ? `${state.loopArgs}${key}` : `${state.bindPrefix}${key}`
editor.value.getEditor().setValue(state.variable)
state.variableName = key
state.variableContent = state.variables[key]
}
}
const cancel = () => {
state.variable = ''
state.isVisible = false
}
const remove = () => {
emit('update:modelValue', '')
cancel()
}
const confirm = async () => {
const editorInstance = editor.value?.getEditor()
await editorInstance.getAction('editor.action.formatDocument').run()
const variableContent = state.isEditorEditMode ? editorInstance.getValue() : state.variable
const { setSaved, getSchema, updateSchema } = useCanvas()
if (oldValue !== variableContent) {
setSaved(false)
}
const pattern = /^[\s]*{[\s]*api[\s]*:[\s\w.]*}$/
const needFetchDataFormat = props.name === 'fetchData' && !pattern.test(variableContent)
if (variableContent) {
if (state.bindPrefix === CONSTANTS.DATASOUCEPREFIX) {
const pageSchema = getSchema()
const stateName = state.variable.replace(`${CONSTANTS.STATE}`, '')
const staticData = state.variableContent.map(({ _id, ...other }) => other)
updateSchema({ state: { ...pageSchema.state, [stateName]: staticData } })
genRemoteMethodToLifeSetup(stateName, state.dataSouce, pageSchema)
}
emit('update:modelValue', {
type: 'JSExpression',
value: needFetchDataFormat ? `{api:${variableContent}}` : variableContent
})
} else {
emit('update:modelValue', '')
}
cancel()
}
const getInitVariable = () => {
if (
props.modelValue?.value &&
props.modelValue?.type === EXPRESSION_TYPE.JS_EXPRESSION &&
Object.keys(props.modelValue || {}).length <= 3
) {
return String(props.modelValue?.value)
}
return ''
}
const dialogShouldInitialize = ref(!props.lazyLoad)
const open = () => {
dialogShouldInitialize.value = true
state.isVisible = true
state.variableName = bindKey.value
state.variable = getInitVariable()
state.variables = useCanvas().getSchema()?.state || {}
state.bindPrefix = CONSTANTS.STATE
state.variableContent = state.variables[bindKey.value]
nextTick(() => window.dispatchEvent(new Event('resize')))
}
const selectItem = (item) => {
state.active = item.id
const { getSchema } = useCanvas()
if (item.id === 'function') {
state.bindPrefix = CONSTANTS.THIS
const { getMethods } = getMetaApi(META_APP.Page)
state.variables = { ...getMethods?.() }
} else if (item.id === 'bridge' || item.id === 'utils') {
state.bindPrefix = `${CONSTANTS.THIS}${item.id}.`
const bridge = {}
useResource().appSchemaState[item.id]?.forEach((res) => {
bridge[res.name] = `${item.id}.${res.content.exportName}`
})
state.variables = bridge
} else if (item.id === 'props') {
state.bindPrefix = CONSTANTS.PROPS
const properties = getSchema()?.schema?.properties
const bindProperties = {}
properties?.forEach(({ content }) => {
content.forEach(({ property }) => {
bindProperties[property] = property
})
})
state.variables = bindProperties
} else if (item.id === 'datasource') {
state.bindPrefix = CONSTANTS.DATASOUCEPREFIX
const url = new URLSearchParams(location.search)
const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
const selectedId = appId || url.get('id')
getMetaApi(META_SERVICE.Http)
.get(`/app-center/api/sources/list/${selectedId}`)
.then((data) => {
const sourceData = {}
data.forEach((res) => {
sourceData[res.name] = res
})
state.variables = sourceData
})
} else if (item.id === 'store') {
state.bindPrefix = CONSTANTS.STORE
state.variables = {}
const stores = useResource().appSchemaState.globalState
stores.forEach(({ id, state: storeState = {}, getters = {} }) => {
const loadProp = (prop) => {
const propBinding = `${id}.${prop}`
state.variables[propBinding] = propBinding
}
Object.keys(storeState).forEach(loadProp)
Object.keys(getters).forEach(loadProp)
})
} else if (item.id === 'loop') {
state.bindPrefix = ''
const [loopItem = DEFAULT_LOOP_NAME.ITEM, loopIndex = DEFAULT_LOOP_NAME.INDEX] =
useProperties().getSchema()?.loopArgs || []
state.variables = [loopItem, loopIndex].reduce((variables, param) => ({ ...variables, [param]: param }), {})
} else if (item.id === 'slotScope') {
state.bindPrefix = ''
const params = getJsSlotParams()
state.variables = params.reduce((variables, param) => ({ ...variables, [param]: param }), {})
} else {
state.bindPrefix = CONSTANTS.STATE
state.variables = getSchema()?.[item.id]
}
}
return {
confirmDisabled,
editorDidMount,
editorChange,
editorOptions,
variableClick,
remove,
cancel,
confirm,
dialogShouldInitialize,
open,
selectItem,
state,
editor,
isDataSource
}
}
}
</script>
<style lang="less" scoped>
.icon {
margin-left: 8px;
}
.meta-bind-variable-dialog-box {
.header-alert {
margin-top: 0;
margin-bottom: 12px;
}
.bind-dialog-content {
display: flex;
align-items: center;
.content-left {
margin-right: 12px;
width: 38%;
.content-left__title {
color: var(--te-configurator-common-text-color-primary);
}
.list-wrap {
border: 1px solid var(--te-configurator-common-border-color-divider);
border-radius: 4px;
height: 300px;
margin-top: 8px;
display: flex;
}
.content-left__list {
width: 120px;
color: var(--te-configurator-common-text-color-secondary);
border-right: 1px solid var(--te-configurator-common-border-color-divider);
}
.content-left__list-item {
padding: 8px 12px;
cursor: pointer;
transition: background 0.3s;
&.active,
&:hover {
background: var(--te-configurator-common-bg-color-hover);
color: var(--te-configurator-common-text-color-primary);
}
}
.item-selected {
background-color: var(--te-configurator-common-bg-color-active);
}
.item-text {
padding: 8px 12px;
cursor: pointer;
color: var(--te-configurator-common-text-color-secondary);
&:hover {
background-color: var(--te-configurator-common-bg-color-hover);
color: var(--te-configurator-common-text-color-primary);
}
}
.content-left__title {
font-weight: 600;
}
.item-content {
padding: 12px;
width: calc(100% - 140px);
.item-content-list {
height: calc(100% - 42px);
overflow-y: auto;
}
}
}
.content-right {
flex: 1 1 0;
width: 60%;
.content-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-name {
display: flex;
align-items: center;
}
}
.content-right__title {
color: var(--te-configurator-common-text-color-primary);
font-weight: 600;
margin-right: 5px;
}
.state-preview {
margin-top: 5px;
}
.content-wrap {
height: 300px;
margin-top: 8px;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
.top {
width: 100%;
height: 54%;
border-radius: 4px;
padding: 12px 8px;
color: var(--te-configurator-common-text-color-primary);
border: 1px solid var(--te-configurator-common-border-color-divider);
box-sizing: border-box;
& > div {
height: 100%;
width: 100%;
}
.datasource-poll-wrap {
display: flex;
align-items: center;
height: 24px;
margin-top: 12px;
.datasource-poll-interval {
margin-left: 16px;
.tiny-input {
width: 120px;
height: 20px;
margin: 0 8px;
}
}
}
}
.poll-top {
& > div {
height: calc(100% - 36px);
width: 100%;
}
}
.bottom {
width: 100%;
height: 40%;
padding: 8px 12px;
border-radius: 4px;
box-sizing: border-box;
overflow: auto;
color: var(--te-configurator-common-text-color-secondary);
border: 1px solid var(--te-configurator-common-border-color-divider);
pre {
font-family: consolas;
}
.bottom-title {
font-weight: var(--te-base-font-weight-6);
color: var(--te-configurator-common-text-color-primary);
}
}
}
}
}
.bind-dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
}
</style>