367 lines
12 KiB
Markdown
367 lines
12 KiB
Markdown
# Vue页面可视化设计器 - 项目设计文档
|
||
|
||
## 项目概述
|
||
|
||
本项目是一个基于 **Vite + Vue3 + TypeScript + Element Plus** 的可视化页面设计器,通过拖拽操作直接编辑真实的Vue源文件,实现低代码页面快速构建。
|
||
|
||
## 技术架构
|
||
|
||
### 技术栈
|
||
|
||
| 类别 | 技术 | 版本 |
|
||
|------|------|------|
|
||
| 构建工具 | Vite | 7.3.0 |
|
||
| 前端框架 | Vue 3 (Composition API) | 3.5.24 |
|
||
| UI组件库 | Element Plus | - |
|
||
| 状态管理 | Pinia | 3.0.4 |
|
||
| 类型系统 | TypeScript | - |
|
||
| 后端运行时 | Node.js | 22.x |
|
||
| 后端框架 | Express | - |
|
||
| 模板解析 | @vue/compiler-sfc, @vue/compiler-dom | - |
|
||
|
||
### 系统架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 前端 (Vue3 + Vite) │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ Designer.vue │
|
||
│ ├── Header (顶部菜单) │
|
||
│ ├── MainLayout (三栏布局) │
|
||
│ │ ├── 左侧面板 (PageManager, DesignComponentList) │
|
||
│ │ ├── 中间面板 (DesignCenter + InteractiveWrapper) │
|
||
│ │ └── 右侧面板 (属性编辑器 - 待实现) │
|
||
│ └── Footer (状态栏 - 显示拖放记录) │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ 状态管理层 │
|
||
│ ├── dragStore.ts (拖拽状态、两阶段逻辑、层级选择) │
|
||
│ ├── panelStore.ts (面板布局状态) │
|
||
│ ├── designStore.ts (设计组件元数据) │
|
||
│ └── vueFileStore.ts (Vue文件选择状态) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
│ HTTP API
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 后端 (Node.js + Express) │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ API: POST /api/move-element │
|
||
│ ├── 接收: { pagePath, source, target, direction } │
|
||
│ └── 处理: templateService.moveElement() │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ templateService.js │
|
||
│ ├── parseSFC() - 解析Vue单文件组件 │
|
||
│ ├── parseTemplate() - 解析template获取AST │
|
||
│ ├── findElementByPath() - 根据路径定位元素 │
|
||
│ ├── moveElement() - 移动元素(同级前后) │
|
||
│ └── moveElementInside() - 移动元素(放入内部) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Vue源文件 (src/views/) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 核心功能模块
|
||
|
||
### 1. 拖拽状态管理 (dragStore.ts)
|
||
|
||
#### 核心状态
|
||
|
||
```typescript
|
||
// 拖拽阶段
|
||
dragPhase: 'source' | 'target'
|
||
|
||
// 拖拽源信息
|
||
dragSource: {
|
||
type: 'design-component' | 'canvas-element'
|
||
componentId?: string
|
||
componentName?: string
|
||
path?: string
|
||
elementType?: 'er' | 'ec'
|
||
}
|
||
|
||
// 已确定的源元素(进入目标阶段时保存)
|
||
confirmedSource: {
|
||
path: string
|
||
type: 'er' | 'ec'
|
||
element: HTMLElement
|
||
}
|
||
|
||
// 层级节点列表(从深到浅排序)
|
||
hierarchyNodes: HierarchyNode[]
|
||
|
||
// 当前选中的层级索引
|
||
selectedHierarchyIndex: number
|
||
|
||
// 拖放方向
|
||
hoverDirection: 'top' | 'bottom' | 'left' | 'right' | 'inside' | null
|
||
```
|
||
|
||
#### 两阶段拖拽流程
|
||
|
||
```
|
||
源选择阶段 (source)
|
||
├── 触发: startDragFromCanvas(path, type, element)
|
||
├── 功能:
|
||
│ ├── 构建当前元素的层级列表
|
||
│ └── ↑↓键切换要拖拽的源层级
|
||
├── 特点:
|
||
│ ├── 不显示DropZone
|
||
│ └── 松开鼠标取消拖拽
|
||
└── 退出: 移动到其他元素 → enterTargetPhase()
|
||
|
||
目标选择阶段 (target)
|
||
├── 触发: enterTargetPhase(targetElement)
|
||
├── 功能:
|
||
│ ├── 保存确定的源元素到 confirmedSource
|
||
│ ├── 构建目标元素的层级列表
|
||
│ └── ↑↓键切换目标层级
|
||
├── 特点:
|
||
│ ├── 显示DropZone
|
||
│ └── 根据源/目标类型显示方向
|
||
└── 退出: 松开鼠标 → confirmDrop()
|
||
```
|
||
|
||
#### 跨类型拖放逻辑
|
||
|
||
```typescript
|
||
const isCrossTypeDrop = computed(() => {
|
||
if (dragPhase.value !== 'target') return false
|
||
if (!confirmedSource.value || !selectedNode.value) return false
|
||
|
||
// 源类型与目标类型不同
|
||
return confirmedSource.value.type !== selectedNode.value.type
|
||
})
|
||
|
||
// 跨类型时显示 'inside' 方向
|
||
if (isCrossTypeDrop.value) {
|
||
hoverDirection.value = 'inside'
|
||
}
|
||
```
|
||
|
||
### 2. 交互注入器 (InteractiveWrapper.vue)
|
||
|
||
#### 核心职责
|
||
|
||
1. **动态渲染Vue页面** - 使用 `defineAsyncComponent` 加载选中的页面
|
||
2. **注入交互事件** - 为所有 el-row/el-col 绑定事件
|
||
3. **监听DOM变化** - 使用 MutationObserver 检测新元素
|
||
4. **键盘事件处理** - ↑↓切换层级,Esc取消
|
||
|
||
#### 事件绑定
|
||
|
||
```typescript
|
||
const bindElementEvents = (element, type, path) => {
|
||
// 标记已绑定
|
||
element.setAttribute('data-fauto-bindend', 'true')
|
||
element.setAttribute('data-path', path)
|
||
element.classList.add('fauto-interactive')
|
||
|
||
// 悬停事件 - 进入目标阶段
|
||
element.addEventListener('mouseenter', (e) => {
|
||
if (dragStore.isDragging) {
|
||
const sourcePath = dragStore.confirmedSource?.path || dragStore.dragSource?.path
|
||
if (sourcePath && path !== sourcePath && !path.startsWith(sourcePath)) {
|
||
dragStore.enterTargetPhase(element)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 按下事件 - 开始拖拽
|
||
element.addEventListener('mousedown', (e) => {
|
||
if (!dragStore.isDragging) {
|
||
dragStore.startDragFromCanvas(path, type, element)
|
||
}
|
||
})
|
||
}
|
||
```
|
||
|
||
#### 全局事件处理
|
||
|
||
```typescript
|
||
// 全局鼠标移动 - 更新拖放方向
|
||
const handleGlobalMouseMove = (e) => {
|
||
if (dragStore.isDragging && dragStore.dragPhase === 'target') {
|
||
dragStore.updateDirectionFromMouse(e.clientX, e.clientY)
|
||
}
|
||
}
|
||
|
||
// 全局鼠标松开 - 确认拖放
|
||
const handleGlobalMouseUp = async () => {
|
||
if (dragStore.isDragging && dragStore.dragPhase === 'target') {
|
||
await dragStore.confirmDrop(vueFileStore.selectedFilePath)
|
||
}
|
||
dragStore.endDrag()
|
||
}
|
||
```
|
||
|
||
### 3. 后端模板服务 (templateService.js)
|
||
|
||
#### 路径解析
|
||
|
||
```javascript
|
||
// 解析路径: "r1c2r1" → [{type:'r',index:1}, {type:'c',index:2}, {type:'r',index:1}]
|
||
function parsePath(pathStr) {
|
||
const nodes = []
|
||
const regex = /([rc])(\d+)/g
|
||
let match
|
||
while ((match = regex.exec(pathStr)) !== null) {
|
||
nodes.push({ type: match[1], index: parseInt(match[2], 10) })
|
||
}
|
||
return nodes
|
||
}
|
||
```
|
||
|
||
#### 元素定位
|
||
|
||
```javascript
|
||
function findElementByPath(ast, pathStr) {
|
||
const pathNodes = parsePath(pathStr)
|
||
let currentChildren = ast.children
|
||
let currentNode = null
|
||
|
||
for (const pathNode of pathNodes) {
|
||
const targetType = pathNode.type === 'r' ? 'el-row' : 'el-col'
|
||
let count = 0
|
||
|
||
for (const child of currentChildren) {
|
||
if (child.type === 1 && child.tag === targetType) {
|
||
count++
|
||
if (count === pathNode.index) {
|
||
currentNode = child
|
||
currentChildren = child.children || []
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return currentNode
|
||
}
|
||
```
|
||
|
||
#### 缩进调整算法
|
||
|
||
```javascript
|
||
function adjustIndentation(sourceText, targetIndent) {
|
||
const lines = sourceText.split('\n')
|
||
|
||
// 获取第一行的原始缩进
|
||
const firstLineIndent = getIndentLevel(lines[0])
|
||
|
||
// 计算缩进差值
|
||
const indentDiff = targetIndent.length - firstLineIndent
|
||
|
||
// 对每一行应用缩进差值(保持相对缩进)
|
||
return lines.map(line => {
|
||
if (!line.trim()) return ''
|
||
const currentIndent = getIndentLevel(line)
|
||
const newIndent = Math.max(0, currentIndent + indentDiff)
|
||
return ' '.repeat(newIndent) + line.trimStart()
|
||
}).join('\n')
|
||
}
|
||
```
|
||
|
||
#### 元素移动(从后向前处理)
|
||
|
||
```javascript
|
||
// 关键:从后向前处理,避免偏移量错误
|
||
if (deleteStart > insertPosition) {
|
||
// 删除位置在后:先删除,再插入
|
||
result = content.substring(0, deleteStart) + content.substring(deleteEnd)
|
||
result = result.substring(0, insertPosition) + insertText + result.substring(insertPosition)
|
||
} else {
|
||
// 删除位置在前:先删除,调整插入位置
|
||
const deletedLength = deleteEnd - deleteStart
|
||
const adjustedInsertPos = insertPosition - deletedLength
|
||
result = content.substring(0, deleteStart) + content.substring(deleteEnd)
|
||
result = result.substring(0, adjustedInsertPos) + insertText + result.substring(adjustedInsertPos)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 开发规范
|
||
|
||
### Vue页面结构规范
|
||
|
||
```vue
|
||
<template>
|
||
<!-- 第一层级必须且只能有一个 el-row -->
|
||
<el-row class="page-container" :gutter="20">
|
||
<!-- el-row 内只能包含 el-col -->
|
||
<el-col :span="12">
|
||
<!-- el-col 内可以放设计组件或嵌套 el-row -->
|
||
<div class="design-component">内容</div>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-row :gutter="10">
|
||
<el-col :span="24">嵌套内容</el-col>
|
||
</el-row>
|
||
</el-col>
|
||
</el-row>
|
||
</template>
|
||
```
|
||
|
||
### 代码规范
|
||
|
||
1. 使用 TypeScript 严格模式
|
||
2. 组件使用 Composition API
|
||
3. Props 必须明确定义类型
|
||
4. 样式使用 scoped 避免污染
|
||
|
||
### 插件开发规范
|
||
|
||
所有与页面交互相关的代码放在 `src/fauto/plugins/` 目录:
|
||
- 状态管理类使用 Pinia defineStore
|
||
- 工具函数单独文件导出
|
||
- 统一通过 `index.ts` 导出
|
||
|
||
---
|
||
|
||
## API 接口
|
||
|
||
### 移动元素
|
||
|
||
```
|
||
POST /api/move-element
|
||
|
||
Request:
|
||
{
|
||
"pagePath": "views/TestPage1.vue", // 相对于 src 的路径
|
||
"source": "r1c1", // 源元素路径
|
||
"target": "r1c2", // 目标元素路径
|
||
"direction": "right" // top|bottom|left|right|inside
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"message": "元素移动成功"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 待实现功能
|
||
|
||
1. **属性编辑器** - 展示和编辑选中组件的属性
|
||
2. **撤销/重做** - 操作历史记录
|
||
3. **设计组件拖入** - 从组件列表拖入新组件
|
||
4. **预览模式** - 隐藏交互层预览最终效果
|
||
5. **导出功能** - 导出设计结果
|
||
|
||
---
|
||
|
||
## 已知问题
|
||
|
||
1. 拖放操作有概率生成错误HTML结构(已基本修复)
|
||
2. 复杂嵌套结构下缩进可能不完美
|
||
|
||
---
|
||
|
||
**最后更新**:2026-01-20
|