feat: 新增运行时与预览,增加事件系统
my-v0 / deploy (push) Failing after 47s

This commit is contained in:
2026-08-17 23:39:33 +08:00
parent 0cd71f60f1
commit 2f10e8da23
26 changed files with 931 additions and 52 deletions
+9 -9
View File
@@ -85,21 +85,21 @@ Element Plus CSS 为 `116.01 kB`gzip `16.05 kB`),随编辑器模块加载
- ~~页面 Schema 导入导出与实时编辑~~
- ~~静态数据源~~
- ~~API 数据源~~
- 数据源管理界面
- API 数据源调试
- 加载、刷新与请求复用
- ~~数据源管理界面~~
- ~~API 数据源调试~~
- ~~加载、刷新与请求复用~~
### 三、运行时与预览能力
- 大屏自适应预览
- 页面持久化与独立访问
- 运行时上下文
- ~~大屏自适应预览~~
- ~~页面持久化与独立访问~~
- ~~运行时上下文~~
### 四、事件系统
- 事件调度机制
- 事件配置面板
- 跨组件事件联动
- ~~事件调度机制~~
- ~~事件配置面板~~
- ~~跨组件事件联动~~
- 事件函数编辑体验优化
- 代码沙箱
- 物料可触发事件声明
+17
View File
@@ -9,12 +9,16 @@
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
@@ -25,10 +29,17 @@ declare global {
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
@@ -46,9 +57,12 @@ declare global {
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
@@ -59,7 +73,10 @@ declare global {
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
+9
View File
@@ -16,19 +16,28 @@ declare module 'vue' {
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
MonacoEditor: typeof import('./src/components/MonacoEditor/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScreenRender: typeof import('./src/components/ScreenRender/index.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+2 -10
View File
@@ -1,12 +1,4 @@
<script setup lang="ts">
import AsyncLoader from '@/components/AsyncLoader.vue'
const ScreenEditor = defineAsyncComponent({
loader: () => import('@/editor/index.vue'),
loadingComponent: AsyncLoader,
delay: 0,
})
const isDesktop = ref(window.innerWidth >= 768)
const updateDevice = () => {
@@ -24,8 +16,8 @@ onBeforeUnmount(() => {
<template>
<main>
<div v-if="isDesktop" class="h-screen overflow-hidden">
<ScreenEditor />
<div v-if="isDesktop || $route.path === '/preview'" class="h-screen overflow-hidden">
<RouterView />
</div>
<section
v-else
+10 -1
View File
@@ -15,6 +15,7 @@ const modelValue = defineModel({ type: String, default: '' })
// 编辑器 DOM 元素的引用
const editorEL = ref()
const editorInstance = shallowRef<editor.IStandaloneCodeEditor>()
// 因为 monaco-editor 运行需要消耗大量的资源,所以需要在webWorker中运行
window.MonacoEnvironment = {
@@ -38,6 +39,7 @@ onMounted(() => {
language: props.lang || 'json',
automaticLayout: true,
})
editorInstance.value = instance
// 监听内容变化
instance.onDidChangeModelContent(() => {
@@ -45,9 +47,16 @@ onMounted(() => {
})
onBeforeUnmount(() => {
instance.dispose()
editorInstance.value?.dispose()
})
})
// 监听外部传入的 modelValue 变化,如果和编辑器内容不一致,则更新编辑器内容
watch(modelValue, (value) => {
if (editorInstance.value && value !== editorInstance.value.getValue()) {
editorInstance.value.setValue(value)
}
})
</script>
<template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { getMaterialComponent } from '@/materials'
import { createRunTimeContext } from '@/runtime/context'
import type { MaterialSchema } from '@/schema/materials'
import type { PageSchema } from '@/schema/page'
defineOptions({
name: 'ScreenRender',
})
const props = defineProps<{
page: PageSchema
}>()
const canvas = computed(() => props.page.canvas)
const dataSource = computed(() => props.page.dataSource)
const nodes = computed(() => props.page.nodes)
const runTimePage = ref(props.page)
const context = createRunTimeContext(runTimePage)
const scale = ref(1)
provide('dataSource', dataSource)
const canvasStyle = computed(() => {
return {
width: canvas.value?.width + 'px',
height: canvas.value?.height + 'px',
backgroundColor: canvas.value?.backgroundColor,
transform: `scale(${scale.value})`,
transformOrigin: 'top left',
}
})
const canvasWrapperStyle = computed(() => {
return {
width: `${canvas.value.width * scale.value}px`,
height: `${canvas.value.height * scale.value}px`,
}
})
// 获取节点的样式
const getNodeStyle = (node: MaterialSchema, zIndex: number) => {
return {
width: `${node.layout.width}px`,
height: `${node.layout.height}px`,
left: `${node.layout.x}px`,
top: `${node.layout.y}px`,
zIndex: zIndex + 1,
}
}
const createEvents = (node: MaterialSchema) => {
const listeners: Record<string, any> = {}
const events = node.events || []
events.forEach((event) => {
event.handler = listeners[event.type] = () => {
const fn = new Function(event.code)
// 执行创建函数
fn()
}
})
return listeners
}
const init = () => {
const scaleX = window.innerWidth / canvas.value.width
const scaleY = window.innerHeight / canvas.value.height
scale.value = Math.min(scaleX, scaleY)
}
const vm = getCurrentInstance()
onMounted(() => {
const refs: Record<string, any> = {}
for (const key in vm?.refs) {
const res = vm?.refs[key]
refs[key] = res
}
context.registerNodeInstance(refs)
init()
window.addEventListener('resize', init)
onBeforeUnmount(() => {
window.removeEventListener('resize', init)
})
})
</script>
<template>
<div class="flex h-full w-full items-center justify-center overflow-hidden">
<div :style="canvasWrapperStyle">
<div class="relative" :style="canvasStyle">
<div
v-for="(node, index) in nodes"
:key="node.id"
:style="getNodeStyle(node, index)"
class="absolute"
v-on="createEvents(node)"
>
<component :ref="node.id" :is="getMaterialComponent(node.type)" :schema="node" />
</div>
</div>
</div>
</div>
</template>
+50 -10
View File
@@ -1,8 +1,11 @@
import type { DataSourceSchema } from '@/schema/page'
import axios from 'axios'
import { getValue } from '@/utils'
export const useDataSource = (dataId: Ref<string>) => {
const dataSource = inject<Ref<DataSourceSchema[]>>('dataSource')
const loading = ref(false)
const error = ref()
// 数据源轮询定时器
let timer: number | null = null
@@ -15,21 +18,20 @@ export const useDataSource = (dataId: Ref<string>) => {
// 加载数据源数据,如果是 api 类型则发起请求
const loadData = async () => {
if (timer) clearTimeout(timer)
if (!source.value) return
// 如果是 api 类型且有 url,则发起请求获取数据
if (source.value.type === 'api' && source.value.url) {
const url = source.value.url
try {
const res = await axios.get(url, {
params: {
...source.value.params,
},
})
console.log('loadData res', res)
data.value = res.data
loading.value = true
error.value = null
const res = await fetchData(source.value)
data.value = res
} catch (err) {
error.value = err
} finally {
loading.value = false
if (source.value.interval) {
if (timer) {
clearTimeout(timer)
@@ -55,6 +57,44 @@ export const useDataSource = (dataId: Ref<string>) => {
return {
data,
loadData,
error,
loading,
refresh: loadData,
}
}
const requestMap: Record<string, Promise<unknown>> = {}
// 获取数据源数据,url相同,params相同则复用请求
export const fetchData = async (
source: Pick<DataSourceSchema, 'type' | 'url' | 'params' | 'method' | 'responsePath'>,
) => {
const queryParams = {
...source.params,
}
const paramsKey = source.method === 'POST' ? 'data' : 'params'
const config = {
url: source.url,
method: source.method || 'GET',
[paramsKey]: queryParams,
}
const key = JSON.stringify(config)
if (requestMap[key]) {
return requestMap[key]
}
const promise = axios
.request(config)
.then((res) => getValue(res.data, source.responsePath))
.finally(() => {
delete requestMap[key]
})
requestMap[key] = promise
return promise
}
+13
View File
@@ -8,12 +8,25 @@ import CanvasRoot from '@/editor/canvas/index.vue'
import PropertyPanel from '@/editor/panels/property/index.vue'
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import { getPublishedPage } from '@/utils/publish'
defineOptions({
name: 'ScreenEditor',
})
const route = useRoute()
const editorStore = useEditorStore()
const { panelVisible, dataSource } = storeToRefs(editorStore)
const pageId = route.query.id as string
if (pageId) {
const page = getPublishedPage(pageId)
if (page) {
editorStore.setPage(page)
}
}
provide('dataSource', dataSource)
const materialWidth = computed(() => (panelVisible.value.material ? '260px' : '0'))
@@ -6,6 +6,7 @@ import { useUndoRedo } from '@/composables/useUndoRedo'
import 'element-plus/es/components/select/style/css'
import 'element-plus/es/components/input/style/css'
import 'element-plus/es/components/checkbox/style/css'
import 'element-plus/es/components/input-number/style/css'
defineProps(['setters', 'formData'])
@@ -0,0 +1,157 @@
<!-- 节点事件配置 -->
<script setup lang="ts">
import type { MaterialEvent } from '@/schema/materials'
import { useEditorStore } from '@/stores/editor'
import { deepClone } from '@/utils'
import { copy } from '@/utils/clipboard'
import { ElMessage } from 'element-plus'
import { storeToRefs } from 'pinia'
defineOptions({
name: 'NodeEvents',
})
const editorStore = useEditorStore()
const { selectedNode, nodes } = storeToRefs(editorStore)
// 深拷贝事件列表,避免未确认时改到 store
const eventList = ref(deepClone(selectedNode.value?.events || []))
const activeEvent = ref<MaterialEvent | null>(null)
const removeEvent = ref<MaterialEvent>()
const removeVisible = ref(false)
const selectEvent = (event: MaterialEvent) => {
activeEvent.value = event
}
// 添加事件
const onAdd = () => {
const newEvent: MaterialEvent = {
title: '未命名',
name: '未命名',
type: '',
code: '',
}
eventList.value.push(newEvent)
selectEvent(newEvent)
}
// 移除事件
const onRemove = (event: MaterialEvent) => {
eventList.value = eventList.value.filter((item) => item !== event)
if (activeEvent.value === event) {
activeEvent.value = eventList.value[0] || null
}
}
// 打开删除确认弹窗
const onOpenRemove = (event: MaterialEvent) => {
removeEvent.value = event
removeVisible.value = true
}
// 取消删除
const onCancelRemove = () => {
removeVisible.value = false
removeEvent.value = undefined
}
// 确认删除事件
const onConfirmRemove = () => {
if (removeEvent.value) {
onRemove(removeEvent.value)
}
onCancelRemove()
}
// 复制节点ID
const copyNodeId = (id: string) => {
copy(id)
ElMessage.success('复制成功')
}
defineExpose({
// 把编辑中的事件写回节点
save() {
const node = selectedNode.value
if (!node) return
editorStore.updateNode(node.id, {
...node,
events: eventList.value,
})
},
})
onMounted(() => {
if (eventList.value.length > 0) {
activeEvent.value = eventList.value[0] ?? null
}
})
</script>
<template>
<div class="flex gap-5">
<div class="w-50 flex-none border border-white/20">
<div class="p-2">
<el-button type="primary" size="small" @click="onAdd">添加</el-button>
</div>
<el-empty v-if="eventList.length === 0" description="暂无事件" :image-size="72" />
<div
v-for="item in eventList"
:key="item.name"
class="flex items-center justify-between h-10 bg-white/10 hover:bg-white/20 cursor-pointer p-2 mb-2 last:mb-0 transition-colors"
:class="{
'!bg-white/70 !text-black': activeEvent === item,
}"
@click="selectEvent(item)"
>
<span>{{ item.title }}</span>
<span @click.stop="onOpenRemove(item)"><Icon icon="mdi:remove"></Icon></span>
</div>
</div>
<div class="flex-1 border border-white/20 p-2 min-h-64">
<div class="mb-4">
<el-select placeholder="复制节点 ID" @change="copyNodeId">
<el-option v-for="node in nodes" :key="node.id" :value="node.id" :label="node.name">
</el-option>
</el-select>
</div>
<el-form v-if="activeEvent">
<el-form-item label="标题">
<el-input v-model="activeEvent.title"></el-input>
</el-form-item>
<el-form-item label="名称">
<el-input v-model="activeEvent.name"></el-input>
</el-form-item>
<el-form-item label="类型">
<el-input v-model="activeEvent.type"></el-input>
</el-form-item>
<el-form-item label="函数体">
<MonacoEditor v-model="activeEvent.code" lang="javascript" class="min-h-80" />
</el-form-item>
</el-form>
<el-empty v-else description="请选择或添加事件" :image-size="96" />
</div>
<el-dialog
v-model="removeVisible"
title="删除事件"
width="400"
append-to-body
@closed="removeEvent = undefined"
>
<span>确认删除事件{{ removeEvent?.title }}</span>
<template #footer>
<el-button @click="onCancelRemove">取消</el-button>
<el-button type="danger" @click="onConfirmRemove">删除</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -1,3 +1,4 @@
<!-- 节点属性面板 -->
<script setup lang="ts">
import { getMaterialSetters } from '@/materials'
import { useEditorStore } from '@/stores/editor'
@@ -5,6 +6,7 @@ import { storeToRefs } from 'pinia'
import FormCreate from './FormCreate.vue'
import DataSource from '@/editor/panels/property/components/DataSource.vue'
import AsyncLoader from '@/components/AsyncLoader.vue'
import NodeEvents from './NodeEvents.vue'
defineOptions({
name: 'NodeProperty',
@@ -18,6 +20,7 @@ const MonacoEditor = defineAsyncComponent({
const editorStore = useEditorStore()
const { selectedNode } = storeToRefs(editorStore)
const nodeEvents = useTemplateRef('nodeEvents')
const setters = computed(() => {
return getMaterialSetters(selectedNode.value?.type || '') ?? []
@@ -51,13 +54,16 @@ const layoutSetters = [
},
]
const visible = ref(false)
const jsonVisible = ref(false)
const eventVisible = ref(false)
const jsonContent = ref('')
// 显示 JSON 编辑器
const showJson = () => {
jsonContent.value = JSON.stringify(selectedNode.value, null, 2)
console.log('jsonContent.value', jsonContent.value)
visible.value = true
jsonVisible.value = true
}
// 确认编辑 JSON
@@ -70,7 +76,13 @@ const onConfirm = () => {
id: selectedNode.value?.id || '',
type: selectedNode.value?.type || '',
})
visible.value = false
jsonVisible.value = false
}
// 确认编辑事件
const onConfirmEvent = () => {
nodeEvents.value?.save()
eventVisible.value = false
}
</script>
@@ -78,7 +90,14 @@ const onConfirm = () => {
<div class="px-2">
<div class="flex items-center justify-between font-bold p-1">
<span>{{ selectedNode?.name }}</span>
<span class="cursor-pointer" @click="showJson"><Icon icon="si:json-duotone" /></span>
<div class="flex gap-2">
<span class="cursor-pointer" @click="eventVisible = true" title="编辑事件">
<Icon icon="solar:bolt-bold-duotone" />
</span>
<span class="cursor-pointer" @click="showJson" title="编辑 JSON">
<Icon icon="si:json-duotone" />
</span>
</div>
</div>
<el-tabs v-model="activeTab" type="border-card" stretch>
@@ -98,14 +117,24 @@ const onConfirm = () => {
</el-tab-pane>
</el-tabs>
<el-drawer v-model="visible" title="编辑 JSON" class="json-drawer" destroy-on-close>
<!-- 编辑 JSON -->
<el-drawer v-model="jsonVisible" title="编辑 JSON" class="json-drawer" destroy-on-close>
<MonacoEditor v-model="jsonContent" lang="json" />
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button @click="jsonVisible = false">取消</el-button>
<el-button type="primary" @click="onConfirm">确认</el-button>
</template>
</el-drawer>
<!-- 事件配置 -->
<el-dialog destroy-on-close title="事件配置" width="800" v-model="eventVisible">
<NodeEvents ref="nodeEvents" />
<template #footer>
<el-button @click="eventVisible = false">取消</el-button>
<el-button type="primary" @click="onConfirmEvent">确认</el-button>
</template>
</el-dialog>
</div>
</template>
+44 -4
View File
@@ -2,9 +2,11 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import AsyncLoader from '@/components/AsyncLoader.vue'
import DataSourceManager from '@/editor/toolbar/components/DataSourceManager.vue'
import { publishPage } from '@/utils/publish'
const MonacoEditor = defineAsyncComponent({
loader: () => import('@/components/MonacoEditor/index.vue'),
@@ -12,13 +14,16 @@ const MonacoEditor = defineAsyncComponent({
delay: 0,
})
const router = useRouter()
const editorStore = useEditorStore()
const { page } = storeToRefs(editorStore)
// 全局数据源
const dataSourceVisible = ref(false)
const visible = ref(false)
const jsonContent = ref('')
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
const DataSourceManagerRef = useTemplateRef('DataSourceManagerRef')
// 显示 JSON 编辑器
const showJson = () => {
@@ -76,19 +81,44 @@ const onFileChange = (e: Event) => {
// 读取文件内容
reader.readAsText(file)
}
// 数据源配置
const onDataSource = () => {
dataSourceVisible.value = true
}
// 保存
const onSave = () => {
DataSourceManagerRef.value?.save()
dataSourceVisible.value = false
}
// 预览
const onPreview = () => {
router.push('/preview')
}
// 发布
const onPublish = () => {
const id = publishPage(page.value)
router.push('/screen?id=' + id)
}
</script>
<template>
<div class="flex items-center justify-end gap-5">
<span class="border p-1 rounded-lg cursor-pointer">
<span class="border p-1 rounded-lg cursor-pointer" title="预览" @click="onPreview">
<Icon icon="icon-park-outline:preview-open"> </Icon>
</span>
<span class="border p-1 rounded-lg cursor-pointer" @click="showJson" title="编辑 JSON">
<Icon icon="si:json-line"> </Icon>
</span>
<span class="border p-1 rounded-lg cursor-pointer">
<span class="border p-1 rounded-lg cursor-pointer" title="发布" @click="onPublish">
<Icon icon="hugeicons:internet"> </Icon>
</span>
<span class="border p-1 rounded-lg cursor-pointer" @click="onDataSource" title="数据源配置">
<Icon icon="mdi:database"> </Icon>
</span>
<span class="border p-1 rounded-lg cursor-pointer" @click="onImport" title="导入">
<Icon icon="lets-icons:import"> </Icon>
</span>
@@ -108,5 +138,15 @@ const onFileChange = (e: Event) => {
</div>
</template>
</el-drawer>
<el-dialog destroy-on-close title="数据源配置" v-model="dataSourceVisible" width="800">
<DataSourceManager ref="DataSourceManagerRef" />
<template #footer>
<div class="flex justify-end gap-2">
<el-button @click="dataSourceVisible = false">取消</el-button>
<el-button @click="onSave">保存</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { fetchData } from '@/composables/usseDataSource'
import type { DataSourceSchema } from '@/schema/page'
import { useEditorStore } from '@/stores/editor'
import { deepClone } from '@/utils'
import { storeToRefs } from 'pinia'
defineOptions({
name: 'DataSourceManager',
})
const editorStore = useEditorStore()
const { dataSource } = storeToRefs(editorStore)
type DataSourceEditor = Omit<DataSourceSchema, 'data' | 'params'> & {
data?: string
params?: string
}
// 深拷贝数据源
const data = ref<DataSourceEditor[]>(
deepClone(dataSource.value).map((item) => {
return {
...item,
data: item.data ? JSON.stringify(item.data, null, 2) : undefined,
params: item.params ? JSON.stringify(item.params, null, 2) : undefined,
}
}),
)
// 请求响应数据
const resData = ref('')
const activeSource = ref<DataSourceEditor>()
const removeSource = ref<DataSourceEditor>()
const removeVisible = ref(false)
const selectDataSource = (source: DataSourceEditor) => {
activeSource.value = source
}
// 添加数据源
const onAdd = () => {
const newSource: DataSourceEditor = {
id: crypto.randomUUID(),
name: '未命名数据源',
type: 'static',
data: '',
params: '{}',
}
data.value.push(newSource)
selectDataSource(newSource)
}
// 移除数据源
const onRemove = (source: DataSourceEditor) => {
const index = data.value.findIndex((item) => item.id === source.id)
if (index !== -1) {
data.value.splice(index, 1)
if (activeSource.value?.id === source.id) {
activeSource.value = undefined
}
}
}
// 打开移除确认弹窗
const onOpenRemove = (source: DataSourceEditor) => {
removeSource.value = source
removeVisible.value = true
}
// 确认移除数据源
const onConfirmRemove = () => {
if (removeSource.value) {
onRemove(removeSource.value)
}
removeVisible.value = false
if (data.value.length > 0) {
activeSource.value = data.value[0]
}
}
// 发送请求
const onRequest = async () => {
const source = activeSource.value
if (!source) return
const res = await fetchData({
type: source.type,
url: source.url,
params: source.params ? JSON.parse(source.params) : undefined,
method: source.method,
responsePath: source.responsePath,
})
console.log('请求结果', res)
resData.value = JSON.stringify(res, null, 2)
}
defineExpose({
save() {
const _data = deepClone(data.value).map((item) => {
return {
...item,
data: item.data ? JSON.parse(item.data) : undefined,
params: item.params ? JSON.parse(item.params) : undefined,
}
})
// 更新数据
editorStore.page.dataSource = _data
},
})
onMounted(() => {
if (data.value.length > 0) {
activeSource.value = data.value[0]
}
})
</script>
<template>
<div class="flex gap-5">
<div class="w-50 flex-none border border-white/20">
<div class="p-2">
<el-button type="primary" size="small" @click="onAdd">添加</el-button>
</div>
<el-empty v-if="data.length === 0" description="暂无数据源" :image-size="72" />
<div
v-for="item in data"
:key="item.id"
class="flex items-center justify-between h-10 bg-white/10 hover:bg-white/20 cursor-pointer p-2 mb-2 last:mb-0 transition-colors"
:class="{
'!bg-white/70 !text-black': activeSource?.id === item.id,
}"
@click="selectDataSource(item)"
>
<span>{{ item.name }}</span>
<span @click.stop="onOpenRemove(item)"><Icon icon="mdi:remove"></Icon></span>
</div>
</div>
<div class="flex-1 border border-white/20 p-2 min-h-64">
<el-form v-if="activeSource">
<el-form-item label="名称">
<el-input v-model="activeSource.name"></el-input>
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="activeSource.type">
<el-radio-button label="静态" value="static"></el-radio-button>
<el-radio-button label="API" value="api"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="数据" v-if="activeSource.type === 'static'">
<MonacoEditor v-model="activeSource.data" lang="json" class="min-h-80" />
</el-form-item>
<!-- api 数据源 -->
<div v-else>
<el-form-item label="请求地址">
<el-input v-model="activeSource.url"></el-input>
</el-form-item>
<el-form-item label="轮询周期">
<el-input v-model="activeSource.interval" placeholder="单位:秒"></el-input>
</el-form-item>
<el-form-item label="请求参数">
<MonacoEditor v-model="activeSource.params" lang="json" class="min-h-80" />
</el-form-item>
<el-form-item label="响应路径">
<el-input v-model="activeSource.responsePath" lang="json" />
</el-form-item>
<el-form-item class="flex">
<el-button type="primary" @click="onRequest">发送请求</el-button>
</el-form-item>
<el-form-item label="请求预览">
<MonacoEditor v-model="resData" lang="json" class="min-h-80" />
</el-form-item>
</div>
</el-form>
<el-empty v-else description="请选择或添加数据源" :image-size="96" />
</div>
<el-dialog v-model="removeVisible" title="删除数据源" width="400">
<span>确认删除数据源{{ removeSource?.name }}</span>
<template #footer>
<el-button @click="removeVisible = false">取消</el-button>
<el-button type="danger" @click="onConfirmRemove">删除</el-button>
</template>
</el-dialog>
</div>
</template>
+7 -4
View File
@@ -15,7 +15,7 @@ const chartRef = useTemplateRef('chartRef')
const dataId = computed(() => props.schema.dataId!)
const { data } = useDataSource(dataId)
const { data, loading, error, refresh } = useDataSource(dataId)
const option = computed(() => {
const _option = props.schema.props.option
@@ -58,10 +58,13 @@ onMounted(() => {
})
}
})
defineExpose({
refresh,
})
</script>
<template>
<div class="w-full h-full" ref="chartRef"></div>
<div v-if="error">{{ error.message }}</div>
<div v-loading="loading" class="w-full h-full" ref="chartRef"></div>
</template>
<style scoped></style>
+10 -2
View File
@@ -5,13 +5,21 @@ defineOptions({
name: 'TextMaterial',
})
defineProps<{
const props = defineProps<{
schema: MaterialSchema
}>()
const textStyle = computed(() => {
const style = props.schema.style
return {
...style,
fontSize: style?.fontSize ? style.fontSize + 'px' : undefined,
}
})
</script>
<template>
<div :style="schema.style">{{ schema.props.content }}</div>
<div :style="textStyle">{{ schema.props.content }}</div>
</template>
<style scoped></style>
+13
View File
@@ -16,6 +16,11 @@ const textMaterial: MaterialDefinition = {
label: '颜色',
key: 'style.color',
},
{
type: 'number',
label: '字号',
key: 'style.fontSize',
},
],
schema: {
type: 'text',
@@ -28,10 +33,18 @@ const textMaterial: MaterialDefinition = {
},
style: {
color: '#fff',
fontSize: 16,
},
props: {
content: '文本内容',
},
events: [
{
type: 'click',
name: 'fn',
code: `console.log(123123123)`,
},
],
},
}
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
defineOptions({
name: 'ScreenPreview',
})
const editorStore = useEditorStore()
const { page } = storeToRefs(editorStore)
</script>
<template>
<div class="flex h-full w-full items-center justify-center overflow-hidden">
<ScreenRender :page="page" />
</div>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { getPublishedPage } from '@/utils/publish'
defineOptions({
name: 'ScreenPage',
})
const route = useRoute()
const page = getPublishedPage(route.query.id as string)
</script>
<template>
<div>
<ScreenRender v-if="page" :page="page" />
</div>
</template>
<style scoped></style>
+21 -1
View File
@@ -2,7 +2,27 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [],
routes: [
{
path: '/',
redirect: '/editor',
},
{
path: '/editor',
name: 'editor',
component: () => import('@/editor/index.vue'),
},
{
path: '/preview',
name: 'preview',
component: () => import('@/pages/preview/index.vue'),
},
{
path: '/screen',
name: 'screen',
component: () => import('@/pages/screen/index.vue'),
},
],
})
export default router
+104
View File
@@ -0,0 +1,104 @@
// 这个文件是为了处理运行时上下文的创建和管理,提供一个统一的接口来获取页面中的节点信息。
import type { MaterialSchema } from '@/schema/materials'
import type { PageSchema } from '@/schema/page'
import { setValue } from '@/utils'
interface RuntimeContext {
/** 获取节点 */
getNode(id: string): MaterialSchema | undefined
/** 设置节点属性 */
setAttribute(id: string, key: string, value: any): void
/** 设置节点的prop */
setProp(id: string, key: string, value: any): void
/** 设置节点的style */
setStyle(id: string, key: string, value: any): void
/** 注册节点实例 */
registerNodeInstance(instance: Record<string, Record<string, any>>): void
/** 更新指定节点实例 */
trigger(id: string, name: string, ...args: any[]): any
/** 刷新节点实例 */
refreshNodeByDataId(id: string, ...args: any[]): void
/** 派发事件到指定节点实例 */
dispatch(id: string, name: string, payload: any): void
}
// 创建运行时上下文
export const createRunTimeContext = (page: Ref<PageSchema>) => {
let InstanceMap: Record<string, Record<string, any>> = {}
const getNode: RuntimeContext['getNode'] = (id: string) => {
return page.value.nodes.find((node) => node.id === id)
}
/** 设置节点属性 */
const setAttribute: RuntimeContext['setAttribute'] = (id: string, key: string, value: any) => {
const node = getNode(id)
if (node) {
setValue(node.props, key, value)
} else {
console.warn(`Node with id ${id} not found`)
}
}
/** 设置节点的prop */
const setProp: RuntimeContext['setProp'] = (id: string, key: string, value: any) => {
setAttribute(id, `props.${key}`, value)
}
/** 设置节点的style */
const setStyle: RuntimeContext['setStyle'] = (id: string, key: string, value: any) => {
setAttribute(id, `style.${key}`, value)
}
/** 注册节点实例 */
const registerNodeInstance: RuntimeContext['registerNodeInstance'] = (instance: any) => {
InstanceMap = instance
}
/** 更新指定节点实例 */
const trigger: RuntimeContext['trigger'] = (id: string, name: string, ...args: any[]) => {
const instance = InstanceMap[id]
if (instance && typeof instance[name] === 'function') {
/** 将函数调用的结果返回,假设调用了123 trigger也返回123 */
return instance[name](...args)
} else {
console.warn(`Instance or method ${name} not found for node with id ${id}`)
}
}
/** 刷新指定dataId的节点实例 */
const refreshNodeByDataId: RuntimeContext['refreshNodeByDataId'] = (
id: string,
...args: any[]
) => {
const nodes = page.value.nodes.filter((node) => node.dataId === id)
nodes.forEach((node) => {
trigger(node.id, 'refresh', ...args)
})
}
/** 派发事件到指定节点实例 */
const dispatch: RuntimeContext['dispatch'] = (id: string, name: string, payload: any) => {
const node = getNode(id)
if (node) {
const event = node.events?.find((e) => e.name === name)
if (event) {
// 如果找到事件就执行
event.handler?.(payload)
}
} else {
console.warn(`Node with id ${id} not found`)
}
}
return {
getNode,
setAttribute,
setProp,
setStyle,
registerNodeInstance,
trigger,
refreshNodeByDataId,
dispatch,
}
}
+10
View File
@@ -5,6 +5,15 @@ interface Layout {
height: number
}
export interface MaterialEvent {
type: string
title: string
name: string
code: string
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
handler?: Function
}
// 物料数据结构
export interface MaterialSchema {
type: string
@@ -15,6 +24,7 @@ export interface MaterialSchema {
layout: Layout
style?: Record<string, any>
props: Record<string, any>
events?: MaterialEvent[]
}
export interface SetterSchema {
+4
View File
@@ -17,12 +17,16 @@ export interface DataSourceSchema {
url?: string
/** api 类型数据源的轮询间隔,单位毫秒,默认不轮询 */
interval?: number
method?: 'GET' | 'POST'
/** 响应数据路径 */
responsePath?: string
/** 预设参数 */
params?: Record<string, any>
}
// 页面数据结构
export interface PageSchema {
id?: string
canvas: CanvasSchema
nodes: MaterialSchema[]
dataSource: DataSourceSchema[]
+18
View File
@@ -0,0 +1,18 @@
// 剪贴板操作
const legacyCopy = (text: string) => {
const input = document.createElement('input')
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
/** 复制文本 */
export const copy = (text: string) => {
try {
navigator.clipboard.writeText(text)
} catch {
legacyCopy(text)
}
}
+32 -4
View File
@@ -1,5 +1,6 @@
// 获取对象中指定路径的值
export function getValue(target: any, key: string) {
/** 获取对象中指定路径的值 */
export const getValue = (target: any, key?: string) => {
if (!key) return target
// 将 'props.content' 转换为 ['props', 'content']
const keys = key.split('.')
while (keys.length) {
@@ -8,8 +9,8 @@ export function getValue(target: any, key: string) {
return target
}
// 设置对象中指定路径的值
export function setValue(target: any, key: string, value: any) {
/** 设置对象中指定路径的值 */
export const setValue = (target: any, key: string, value: any) => {
const keys = key.split('.')
const lastKey = keys.pop()!
@@ -19,3 +20,30 @@ export function setValue(target: any, key: string, value: any) {
target[lastKey] = value
}
/** 深拷贝对象 */
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as any
}
if (Array.isArray(obj)) {
const arrCopy = [] as any[]
for (const item of obj) {
arrCopy.push(deepClone(item))
}
return arrCopy as any
}
const objCopy = {} as { [key: string]: any }
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
objCopy[key] = deepClone((obj as any)[key])
}
}
return objCopy as T
}
+28
View File
@@ -0,0 +1,28 @@
import type { PageSchema } from '@/schema/page'
const PUBLISHED_PAGE_STORAGE_KEY = 'my-v0:published-page'
/** 发布页面 */
export const publishPage = (page: PageSchema) => {
const value = JSON.parse(localStorage.getItem(PUBLISHED_PAGE_STORAGE_KEY) || '{}') as Record<
string,
PageSchema
>
const id = page.id || crypto.randomUUID()
value[id] = page
page.id = id
localStorage.setItem(PUBLISHED_PAGE_STORAGE_KEY, JSON.stringify(value))
return id
}
/** 获取已发布的页面 */
export const getPublishedPage = (id: string): PageSchema | null => {
const value = localStorage.getItem(PUBLISHED_PAGE_STORAGE_KEY)
const map = JSON.parse(value || '{}') as Record<string, PageSchema>
const page = map[id]
if (!page) {
throw new Error(`Published page with id ${id} not found`)
}
return page
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default defineConfig({
vue(),
vueDevTools(),
AutoImport({
imports: ['vue'],
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
}),
Components({