feat: 接入编辑器和导入导出

This commit is contained in:
2026-08-16 18:16:18 +08:00
parent 191069cf31
commit 0fe7d69dba
19 changed files with 334 additions and 54 deletions
+2
View File
@@ -37,3 +37,5 @@ __screenshots__/
# Vite
*.timestamp-*-*.mjs
AGENTS.md
+79 -34
View File
@@ -1,48 +1,93 @@
# my-v0
This template should help get you started developing with Vue 3 in Vite.
Vue 3 + Vite 大屏低代码编辑器前端
## Recommended IDE Setup
## 开发
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
Node `^22.18.0 || >=24.12.0`,包管理器用 pnpm。
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
pnpm build # vue-tsc --build + vite build
pnpm type-check
pnpm preview
pnpm lint # oxlint + eslint(都会 --fix
pnpm format # prettier --write src/
```
### Lint with [ESLint](https://eslint.org/)
## 项目目标
```sh
pnpm lint
```
### 一、编辑器核心架构
- ~~项目初始化与基础布局~~
- ~~工具栏实现~~
- ~~物料面板(可扩展架构)~~
- ~~图层面板~~
- ~~插件化注册机制~~
- ~~物料 DSL 设计~~
- ~~拖拽渲染流程~~
- ~~节点拖动与缩放~~
- ~~框选与多选操作~~
- ~~画布标尺接入~~
- ~~图层排序与画布层级联动~~
- ~~页面 DSL 设计与落地~~
- ~~属性面板~~
- ~~右键菜单~~
- ~~组合式函数拆分~~
- ~~撤销重做基础能力~~
- ~~批处理与历史记录管理~~
- ~~ECharts 图表物料接入~~
### 二、代码与数据能力
- ~~网页代码编辑器接入~~
- ~~页面 Schema 导入导出与实时编辑~~
- 静态数据源
- API 数据源
- 数据源管理界面
- API 数据源调试
- 加载、刷新与请求复用
### 三、运行时与预览能力
- 大屏自适应预览
- 页面持久化与独立访问
- 运行时上下文
### 四、事件系统
- 事件调度机制
- 事件配置面板
- 跨组件事件联动
- 事件函数编辑体验优化
- 代码沙箱
- 物料可触发事件声明
### 五、AI Agent 核心能力
- 大模型接入与调试
- 提示词工程
- 上下文工程
- 结构化输出
- 工具调用
- Agent Loop
- 模型记忆
- RAG 与文档处理
- Agentic RAG
- Skill 体系
- MCP
- 流程编排(LangGraph
- 状态管理与人工介入
- 子图与节点重试
- Responses API
### 六、全栈应用落地
- 项目整体搭建
- 最小会话构建
- AI 能力与设计器深度融合
- 完整业务场景落地
+3
View File
@@ -11,15 +11,18 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
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']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
MonacoEditor: typeof import('./src/components/MonacoEditor/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+1
View File
@@ -18,6 +18,7 @@
"@tailwindcss/postcss": "^4.3.3",
"echarts": "^6.1.0",
"element-plus": "^2.14.4",
"monaco-editor": "0.55.1",
"pinia": "^4.0.2",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
+31
View File
@@ -17,6 +17,9 @@ importers:
element-plus:
specifier: ^2.14.4
version: 2.14.4(vue@3.5.41(typescript@6.0.3))
monaco-editor:
specifier: 0.55.1
version: 0.55.1
pinia:
specifier: ^4.0.2
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
@@ -750,6 +753,9 @@ packages:
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -1079,6 +1085,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
@@ -1545,6 +1554,11 @@ packages:
magic-string@1.2.0:
resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@@ -1567,6 +1581,9 @@ packages:
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
moveable@0.53.0:
resolution: {integrity: sha512-71jS9zIoQzMhnNvduhg4tUEdm23+fO/40FN7muVMbZvVwbTku2MIxxLhnU4qFvxI4oVxn75l79SbtgjuA+s7Pw==}
@@ -2658,6 +2675,9 @@ snapshots:
'@types/sortablejs@1.15.9': {}
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
@@ -3063,6 +3083,10 @@ snapshots:
detect-libc@2.1.2: {}
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
echarts@6.1.0:
dependencies:
tslib: 2.3.0
@@ -3470,6 +3494,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@14.0.0: {}
memoize-one@6.0.0: {}
memorystream@0.3.1: {}
@@ -3492,6 +3518,11 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
moveable@0.53.0:
dependencies:
'@daybrush/utils': 1.13.0
+55
View File
@@ -0,0 +1,55 @@
<!-- 代码编辑器 -->
<script setup lang="ts">
import { editor } from 'monaco-editor'
import 'monaco-editor/min/vs/editor/editor.main.css'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
defineOptions({
name: 'MonacoEditor',
})
const props = defineProps<{ lang: string }>()
const modelValue = defineModel({ type: String, default: '' })
// 编辑器 DOM 元素的引用
const editorEL = ref()
// 因为 monaco-editor 运行需要消耗大量的资源,所以需要在webWorker中运行
window.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') {
return new JsonWorker()
}
if (label === 'typescript' || label === 'javascript') {
return new TsWorker()
}
return new EditorWorker()
},
}
onMounted(() => {
const instance = editor.create(editorEL.value, {
value: modelValue.value,
theme: 'vs-dark',
fontSize: 14,
tabSize: 2,
language: props.lang || 'json',
automaticLayout: true,
})
// 监听内容变化
instance.onDidChangeModelContent(() => {
modelValue.value = instance.getValue()
})
onBeforeUnmount(() => {
instance.dispose()
})
})
</script>
<template>
<div ref="editorEL" class="w-full h-full min-h-0 flex-1"></div>
</template>
+1 -1
View File
@@ -83,7 +83,7 @@ const onCommand = (command: keyof typeof commendMap) => {
</script>
<template>
<div ref="canvasRootRef" class="h-full w-full min-h-0 min-w-0 overflow-hidden">
<div ref="canvasRootRef" class="relative h-full w-full min-h-0 min-w-0 overflow-hidden isolate">
<SketchRuler
v-if="viewportSize.width && viewportSize.height"
:palette="palette"
+1 -1
View File
@@ -24,7 +24,7 @@ const layerWidth = computed(() => (panelVisible.value.layer ? '260px' : '0'))
<div class="h-screen select-none">
<header class="flex items-center justify-between h-14 border-b border-white/20 px-5">
<div class="w-75"><ToolbarLeft /></div>
<div class="">2</div>
<div class="">工作区</div>
<div class="w-75"><ToolbarRight /></div>
</header>
<main class="h-[calc(100%-56px)] flex">
+3 -3
View File
@@ -24,14 +24,14 @@ const nodeList = computed(() => [...nodes.value].reverse())
<div
v-for="node in nodeList"
:key="node.id"
class="flex items-center justify-between p-1 border border-white/20"
class="flex items-center justify-between p-1 border border-white/20 text-(--el-text-color-regular) cursor-pointer"
:class="{
'bg-white/20': selectedNodeIds.includes(node.id),
'bg-white/20 text-white!': selectedNodeIds.includes(node.id),
}"
@click="editorStore.selectNode(node.id)"
>
<span>{{ node.name }}</span>
<span><Icon /></span>
<span><Icon icon="famicons:menu-outline" /></span>
</div>
</div>
</div>
@@ -13,7 +13,7 @@ function onStart(event: DragEvent) {
<template>
<div
class="h-30 p-2.5 flex flex-col border border-white/20"
class="h-30 p-2.5 flex flex-col border border-white/20 rounded cursor-pointer text-(--el-text-color-regular) hover:text-white hover:bg-white/10"
draggable="true"
@dragstart="onStart"
>
+1 -1
View File
@@ -22,7 +22,7 @@ const currentMaterials = computed(() => {
<div
v-for="value in groups"
:key="value.key"
class="cursor-pointer flex flex-col items-center justify-center text-sm h-12"
class="cursor-pointer flex flex-col items-center justify-center text-sm text-(--el-text-color-regular) h-12"
:class="{ 'bg-white/10 text-white': activeGroup === value.key }"
@click="activeGroup = value.key"
>
@@ -2,6 +2,10 @@
import { getValue } from '@/utils'
import { ElInput, ElInputNumber, ElColorPicker, ElCheckbox, ElSelect } from 'element-plus'
import { useUndoRedo } from '@/composables/useUndoRedo'
// 动态 :is 不会走 unplugin 按需样式,需要手动引入
import 'element-plus/es/components/select/style/css'
import 'element-plus/es/components/input/style/css'
import 'element-plus/es/components/checkbox/style/css'
defineProps(['setters', 'formData'])
@@ -32,5 +36,3 @@ const componentMap: Record<string, any> = {
</el-form>
</div>
</template>
<style scoped></style>
@@ -3,6 +3,7 @@ import { getMaterialSetters } from '@/materials'
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import FormCreate from './FormCreate.vue'
import MonacoEditor from '@/components/MonacoEditor/index.vue'
defineOptions({
name: 'NodeProperty',
@@ -41,10 +42,36 @@ const layoutSetters = [
type: 'number',
},
]
const visible = 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
}
// 确认编辑 JSON
const onConfirm = () => {
// 拿到新的 JSON 内容,更新到节点中
const newNode = JSON.parse(jsonContent.value)
editorStore.updateNode(selectedNode.value?.id || '', {
...newNode,
// 这两个属性不允许被修改
id: selectedNode.value?.id || '',
type: selectedNode.value?.type || '',
})
visible.value = false
}
</script>
<template>
<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>
<el-collapse v-model="activeNames">
<el-collapse-item title="布局属性" name="layout">
<FormCreate :setters="layoutSetters" :form-data="selectedNode" />
@@ -53,5 +80,22 @@ const layoutSetters = [
<FormCreate :setters="setters" :form-data="selectedNode" />
</el-collapse-item>
</el-collapse>
<el-drawer v-model="visible" 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 type="primary" @click="onConfirm">确认</el-button>
</template>
</el-drawer>
</div>
</template>
<style scoped>
:deep(.json-drawer .el-drawer__body) {
display: flex;
min-height: 0;
padding: 0;
}
</style>
+88 -5
View File
@@ -1,16 +1,99 @@
<!-- 右侧工具栏 -->
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
const editorStore = useEditorStore()
const { page } = storeToRefs(editorStore)
const visible = ref(false)
const jsonContent = ref('')
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
// 显示 JSON 编辑器
const showJson = () => {
visible.value = true
jsonContent.value = JSON.stringify(page.value, null, 2)
}
// 确认编辑 JSON
const onConfirm = () => {
const newPage = JSON.parse(jsonContent.value)
editorStore.setPage(newPage)
visible.value = false
}
// 导出文件
const onExport = () => {
const json = JSON.stringify(page.value, null, 2)
const blob = new Blob([json], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'design.json'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// 导入文件
const onImport = () => {
inputRef.value?.click()
}
// 文件选择变化处理
const onFileChange = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const reader = new FileReader()
// 读取文件完成后的回调
reader.onload = (e) => {
const content = e.target?.result as string
try {
const newPage = JSON.parse(content)
editorStore.setPage(newPage)
ElMessage.success('导入成功')
} catch (error) {
console.error('导入的文件不是有效的 JSON 文件', error)
ElMessage.error('导入的文件不是有效的 JSON 文件')
}
}
// 读取文件内容
reader.readAsText(file)
}
</script>
<template>
<div class="flex items-center justify-end gap-5">
<span class="border p-1 rounded-lg"><Icon icon="icon-park-outline:preview-open"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="si:json-line"></Icon></span>
<span class="border p-1 rounded-lg" @click="showJson"><Icon icon="si:json-line"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="hugeicons:internet"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="lets-icons:import"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="lets-icons:export"></Icon></span>
<span class="border p-1 rounded-lg" @click="onImport" title="导入">
<Icon icon="lets-icons:import"> </Icon>
</span>
<span class="border p-1 rounded-lg" @click="onExport" title="导出">
<Icon icon="lets-icons:export"> </Icon>
</span>
<input ref="inputRef" type="file" class="hidden" @change="onFileChange" />
<el-drawer v-model="visible" title="编辑 JSON" class="json-drawer" destroy-on-close>
<MonacoEditor v-model="jsonContent" lang="json" />
<template #footer>
<div class="flex justify-end gap-2">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onConfirm">确认</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped></style>
+2 -2
View File
@@ -163,10 +163,10 @@ export const areaMaterial: MaterialDefinition = {
},
lineStyle: {
width: 3,
color: '#22d3ee',
color: '#ffffff',
},
itemStyle: {
color: '#22d3ee',
color: '#ffffff',
},
areaStyle: {
color: 'rgba(34, 211, 238, 0.25)',
+1 -1
View File
@@ -152,7 +152,7 @@ export const barMaterial: MaterialDefinition = {
y: 'sales',
},
itemStyle: {
color: '#22d3ee',
color: '#ffffff',
borderRadius: [4, 4, 0, 0],
},
},
+1 -1
View File
@@ -59,7 +59,7 @@ export const pieMaterial: MaterialDefinition = {
},
props: {
option: {
color: ['#22d3ee', '#a78bfa', '#f59e0b', '#34d399', '#fb7185'],
color: ['#ffffff', '#a78bfa', '#f59e0b', '#34d399', '#fb7185'],
legend: {
top: 38,
left: 'center',
+2 -2
View File
@@ -27,10 +27,10 @@ const textMaterial: MaterialDefinition = {
height: 50,
},
style: {
color: 'red',
color: '#fff',
},
props: {
content: '文本内容123123',
content: '文本内容',
},
},
}
+14
View File
@@ -23,6 +23,11 @@ export const useEditorStore = defineStore('editor', () => {
nodes: [],
})
// 设置页面数据
const setPage = (newPage: PageSchema) => {
Object.assign(page.value, newPage)
}
const canvas = toRef(page.value, 'canvas')
// 画布上的节点数据
@@ -108,6 +113,13 @@ export const useEditorStore = defineStore('editor', () => {
applyChange(node, 'locked', !node.locked)
}
//
function updateNode(nodeId: string, newNode: MaterialSchema) {
// 返回新的节点数组,替换掉原来的节点数组
const newNodes = nodes.value.map((node) => (node.id === nodeId ? newNode : node))
setNodes(newNodes)
}
return {
panelVisible,
page,
@@ -126,5 +138,7 @@ export const useEditorStore = defineStore('editor', () => {
moveTop,
moveBottom,
toggleLock,
updateNode,
setPage,
}
})