Compare commits

...

13 Commits

Author SHA1 Message Date
wfz
4f7059d88f ai牛逼 2026-01-20 22:42:03 +08:00
wfz
842a132ec6 ai牛逼 2026-01-20 22:07:05 +08:00
wfz
bfa4e3107f ai牛逼 2026-01-20 21:53:09 +08:00
wfz
4a90340ab3 初步完成 2026-01-20 20:25:03 +08:00
wfz
1bf26e6e71 31 2026-01-20 20:02:38 +08:00
wfz
ad2322b553 31 2026-01-20 19:43:40 +08:00
wfz
378fb65c76 3 2025-12-22 23:54:48 +08:00
wfz
9829b91321 3 2025-12-22 23:20:40 +08:00
wfz
ff8a6a28f8 2 2025-12-22 22:24:39 +08:00
wfz
3c38f1bee9 2 2025-12-21 21:26:20 +08:00
wfz
c6077ff2ad 2 2025-12-21 20:58:34 +08:00
83bafa4e1e 234 2025-12-20 23:23:21 +08:00
7d2ce711dd 234 2025-12-20 23:23:05 +08:00
59 changed files with 8116 additions and 1032 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

6
.idea/MarsCodeWorkspaceAppSettings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.codeverse.userSettings.MarscodeWorkspaceAppSettingsState">
<option name="progress" value="1.0" />
</component>
</project>

9
.idea/fauto-design.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fauto-design.iml" filepath="$PROJECT_DIR$/.idea/fauto-design.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

597
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,597 @@
# Fauto Design - 开发指南
本文档为开发者提供项目的开发规范、扩展指南和最佳实践。
---
## 目录
1. [开发环境配置](#1-开发环境配置)
2. [代码规范](#2-代码规范)
3. [Vue页面规范](#3-vue页面规范)
4. [设计组件开发](#4-设计组件开发)
5. [物料组件开发](#5-物料组件开发)
6. [插件开发](#6-插件开发)
7. [后端服务扩展](#7-后端服务扩展)
8. [常见问题与经验](#8-常见问题与经验)
---
## 1. 开发环境配置
### 1.1 前端环境
```bash
cd draggable-panels
npm install
npm run dev
```
开发服务器:`http://localhost:5173`
设计器入口:`http://localhost:5173/draggable`
### 1.2 后端环境
```bash
cd vue-template-service
npm install
node src/index.js
```
API服务`http://localhost:3001`
### 1.3 推荐IDE配置
- VS Code + Volar 扩展
- 启用 TypeScript 严格模式
- 配置 ESLint + Prettier
---
## 2. 代码规范
### 2.1 TypeScript规范
```typescript
// ✅ 正确:明确定义类型
interface UserInfo {
id: string
name: string
age: number
}
const user: UserInfo = { id: '1', name: 'Tom', age: 18 }
// ❌ 错误使用any
const user: any = { ... }
```
### 2.2 Vue组件规范
```vue
<script setup lang="ts">
// 1. 导入语句
import { ref, computed, onMounted } from 'vue'
import type { PropType } from 'vue'
import { useStore } from '../stores/xxx'
// 2. Props定义必须有类型
const props = defineProps<{
title: string
count?: number
}>()
// 3. Emits定义
const emit = defineEmits<{
(e: 'update', value: string): void
(e: 'close'): void
}>()
// 4. 响应式状态
const loading = ref(false)
const data = ref<DataItem[]>([])
// 5. 计算属性
const isEmpty = computed(() => data.value.length === 0)
// 6. 方法
const fetchData = async () => { ... }
// 7. 生命周期
onMounted(() => { ... })
</script>
<template>
<!-- 模板内容 -->
</template>
<style scoped>
/* 使用scoped避免样式污染 */
</style>
```
### 2.3 文件命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| Vue组件 | PascalCase | `UserProfile.vue` |
| TypeScript文件 | camelCase | `dragStore.ts` |
| 目录 | camelCase/PascalCase | `designComponents/` |
| JSON配置 | camelCase | `index.json` |
### 2.4 目录职责
```
draggable-panels/src/fauto/
├── components/ # 基础UI组件布局相关
├── materials/ # 物料组件(可拖拽到面板的功能组件)
├── designComponents/ # 设计组件(可拖拽到页面的业务组件)
├── plugins/ # 插件系统(交互、拖拽、路径等核心逻辑)
├── stores/ # Pinia状态管理
└── types/ # 类型定义
```
---
## 3. Vue页面规范
### 3.1 结构要求
**强制规则**template第一层级**有且仅有一个el-row**
```vue
<template>
<!-- 正确单一el-row根元素 -->
<el-row :gutter="20">
<el-col :span="12">...</el-col>
<el-col :span="12">...</el-col>
</el-row>
</template>
```
```vue
<template>
<!-- 错误多个根元素 -->
<el-row>...</el-row>
<el-row>...</el-row>
</template>
```
### 3.2 设计组件标识
页面中的设计组件需要 `data-component` 属性:
```html
<el-col :span="12">
<div class="design-component" data-component="输入框">
<el-form-item label="用户名">
<el-input v-model="username" />
</el-form-item>
</div>
</el-col>
```
**关键点**
- `data-component` 的值必须与设计组件的 `name` 一致
- 用于元数据面板识别组件类型并加载对应的属性schema
### 3.3 结构化路径
系统会自动为el-row/el-col生成路径ID
| 路径 | 含义 |
|------|------|
| `r1` | 第1个el-row |
| `r1c2` | 第1个el-row的第2个el-col |
| `r1c2r1` | 第1个row → 第2个col → 第1个row |
| `r1c2r1c3` | 更深层嵌套... |
---
## 4. 设计组件开发
### 4.1 创建步骤
1.`draggable-panels/src/fauto/designComponents/` 创建目录
2. 添加 `index.json` 配置文件
3. 添加 `template.html` 模板文件
### 4.2 配置文件 (index.json)
```json
{
"id": "MyInput",
"name": "我的输入框",
"icon": "✏️",
"description": "自定义输入组件",
"defaultSpan": 12,
"metadata": {
"span": {
"label": "宽度",
"type": "number",
"min": 1,
"max": 24,
"target": "el-col",
"attr": ":span"
},
"label": {
"label": "标签",
"type": "text",
"target": "el-form-item",
"attr": "label"
},
"placeholder": {
"label": "占位符",
"type": "text",
"target": "el-input",
"attr": "placeholder"
},
"disabled": {
"label": "禁用",
"type": "boolean",
"target": "el-input",
"attr": "disabled"
},
"size": {
"label": "尺寸",
"type": "select",
"options": ["", "large", "default", "small"],
"target": "el-input",
"attr": "size"
},
"textColor": {
"label": "文字颜色",
"type": "color",
"target": "el-input",
"attr": "text-color"
}
}
}
```
### 4.3 元数据类型
| type | 说明 | 渲染控件 |
|------|------|----------|
| `number` | 数字 | 数字输入框 |
| `text` | 文本 | 文本输入框 |
| `select` | 选择 | 下拉选择框 |
| `boolean` | 布尔 | 开关/复选框 |
| `color` | 颜色 | 颜色选择器 |
| `columns` | 列配置 | (暂不支持) |
### 4.4 模板文件 (template.html)
```html
<el-col :span="12">
<div class="design-component design-my-input" data-component="我的输入框">
<el-form-item label="标签">
<el-input v-model="value" placeholder="请输入"></el-input>
</el-form-item>
</div>
</el-col>
```
**模板规范**
- 外层必须是 `el-col`
- 内层 `div` 必须有 `class="design-component"``data-component`
- `data-component` 值与 `index.json``name` 一致
### 4.5 扩展元数据类型
如需添加新的元数据类型:
1.`designStore.ts``MetadataField.type` 添加类型
2.`DataTable/index.vue` 添加对应的编辑控件
```typescript
// designStore.ts
export interface MetadataField {
type: 'text' | 'number' | 'select' | 'boolean' | 'color' | 'myNewType'
// ...
}
```
```vue
<!-- DataTable/index.vue -->
<template v-else-if="field.type === 'myNewType'">
<MyNewTypeEditor :value="localValues[key]" @change="handleChange" />
</template>
```
---
## 5. 物料组件开发
### 5.1 创建步骤
1.`draggable-panels/src/fauto/materials/` 创建目录
2. 添加 `index.vue` 组件文件
3. 添加 `index.json` 配置文件
4.`materials/index.ts` 注册
### 5.2 组件文件 (index.vue)
```vue
<script setup lang="ts">
import config from './index.json'
// 组件逻辑
</script>
<template>
<div class="my-material">
<div class="material-header">
<span class="title">{{ config.name }}</span>
</div>
<div class="material-body">
<!-- 物料组件内容 -->
</div>
</div>
</template>
<style scoped>
.my-material {
height: 100%;
display: flex;
flex-direction: column;
}
</style>
```
### 5.3 配置文件 (index.json)
```json
{
"id": "MyMaterial",
"name": "我的物料",
"icon": "🔧",
"description": "物料组件描述",
"category": "tool",
"defaultPanel": "left"
}
```
### 5.4 注册物料
```typescript
// materials/index.ts
export { default as MyMaterial } from './MyMaterial/index.vue'
export const materialConfigs = {
// ...
MyMaterial: () => import('./MyMaterial/index.json')
}
```
---
## 6. 插件开发
### 6.1 插件位置
所有与交互、拖拽、路径相关的核心逻辑放在 `draggable-panels/src/fauto/plugins/`
### 6.2 创建新插件
```typescript
// plugins/myPlugin.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useMyPlugin = defineStore('myPlugin', () => {
// 状态
const state = ref<MyState>(initialState)
// 计算属性
const derivedValue = computed(() => ...)
// 方法
const doSomething = () => { ... }
return {
state,
derivedValue,
doSomething
}
})
```
### 6.3 统一导出
```typescript
// plugins/index.ts
export { useMyPlugin } from './myPlugin'
```
---
## 7. 后端服务扩展
### 7.1 添加新API
```javascript
// vue-template-service/src/index.js
app.post('/api/my-endpoint', async (req, res) => {
try {
const { param1, param2 } = req.body
// 业务逻辑
const result = await myService(param1, param2)
res.json({ success: true, data: result })
} catch (error) {
console.error('[API] 错误:', error)
res.status(500).json({ success: false, error: error.message })
}
})
```
### 7.2 添加新服务函数
```javascript
// vue-template-service/src/services/templateService.js
export function myNewFunction(vueContent, options) {
try {
// 1. 解析Vue文件
const sfcResult = parseSFC(vueContent)
const templateBlock = sfcResult.descriptor.template
// 2. 解析template AST
const ast = parseTemplate(templateBlock.content, {
comments: true,
whitespace: 'preserve'
})
// 3. 处理逻辑
// ...
return { success: true, data: ... }
} catch (error) {
return { success: false, error: error.message }
}
}
```
---
## 8. 常见问题与经验
### 8.1 拖拽相关
#### 两阶段拖拽
拖拽分为源选择和目标选择两个阶段,避免误操作:
```typescript
// dragStore.ts
dragPhase: 'source' | 'target'
// 源选择阶段:收集层级节点
// 目标选择阶段:选择放置位置
```
#### 层级选择持久化
在目标选择阶段切换层级时,需要记住之前的选择:
```typescript
// 当移动到新元素时,尝试保持相同的层级深度
if (previousSelectedIndex !== null) {
selectedHierarchyIndex = Math.min(previousSelectedIndex, nodes.length - 1)
}
```
### 8.2 交互相关
#### 屏蔽浏览器右键菜单
实现自定义右键菜单时必须阻止默认行为:
```typescript
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// 显示自定义菜单
}
```
#### 多视图联动
设计中心、结构树、元数据面板需要同步选中状态:
```typescript
// 使用自定义事件
window.dispatchEvent(new CustomEvent('design-component-selected', {
detail: { path, componentName }
}))
// 其他组件监听
window.addEventListener('design-component-selected', handler)
```
### 8.3 后端相关
#### 保持代码格式
修改Vue文件时使用字符串操作而非AST重建保持原始格式
```javascript
// ✅ 正确:字符串替换
const newContent = content.substring(0, start) + newCode + content.substring(end)
// ❌ 避免AST重建会丢失格式
const newAST = transform(ast)
const newContent = generate(newAST)
```
#### 热更新触发
修改Vue文件后触发事件让前端刷新
```typescript
window.dispatchEvent(new CustomEvent('vue-template-updated', {
detail: { pagePath }
}))
```
### 8.4 性能优化
#### 防止重复绑定
使用标记属性避免重复绑定事件:
```typescript
if (element.hasAttribute('data-fauto-bindend')) return
element.setAttribute('data-fauto-bindend', 'true')
```
#### MutationObserver
监听DOM变化动态注入事件
```typescript
const observer = new MutationObserver((mutations) => {
// 检查是否有新的el-row/el-col
if (hasNewElements) {
injectInteractionEvents()
}
})
observer.observe(container, { childList: true, subtree: true })
```
---
## 9. 调试技巧
### 9.1 前端调试
```typescript
// 在关键位置添加日志
console.log('[模块名] 操作:', { data })
// 使用Vue DevTools查看Pinia状态
// 使用浏览器DevTools的Elements面板查看data-path属性
```
### 9.2 后端调试
```javascript
// 添加详细日志
console.log(`[API] 请求: ${req.path}`, req.body)
console.log(`[Service] 处理结果:`, result)
```
### 9.3 常用调试命令
```bash
# 查看端口占用
netstat -ano | findstr :3001
# 杀死进程
taskkill /F /PID <pid>
```
---
**文档更新时间**2026-01-20

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Fauto Design
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

212
README.md Normal file
View File

@@ -0,0 +1,212 @@
# Fauto Design - Vue页面可视化设计器
<p align="center">
<img src="https://img.shields.io/badge/Vue-3.5-4FC08D?style=flat-square&logo=vue.js" alt="Vue 3.5">
<img src="https://img.shields.io/badge/TypeScript-5.0-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/Vite-7.3-646CFF?style=flat-square&logo=vite" alt="Vite">
<img src="https://img.shields.io/badge/Element%20Plus-Latest-409EFF?style=flat-square" alt="Element Plus">
<img src="https://img.shields.io/badge/License-MIT-yellow?style=flat-square" alt="MIT License">
</p>
## 📖 简介
**Fauto Design** 是一个基于 Vue3 的可视化页面设计器通过拖拽操作直接编辑真实的Vue源文件实现**所见即所得**的低代码开发体验。
### ✨ 特性
- 🎯 **源码级编辑** - 拖拽操作直接修改Vue文件支持热更新
- 🔧 **非侵入式设计** - 无需修改业务页面代码即可实现可视化编辑
- 🎨 **智能层级选择** - 键盘方向键精准切换嵌套元素层级
- 📝 **元数据编辑** - 可视化修改组件属性(宽度、颜色、尺寸等)
- 🌳 **结构树视图** - 清晰展示页面el-row/el-col结构
- 🔄 **多视图联动** - 设计中心、结构树、属性面板实时同步
## 🚀 快速开始
### 环境要求
- Node.js >= 18
- npm >= 9
### 安装与运行
```bash
# 克隆项目
git clone <repository-url>
cd fauto-design
# 启动前端
cd draggable-panels
npm install
npm run dev
# 新开终端,启动后端
cd vue-template-service
npm install
node src/index.js
```
### 访问设计器
```
http://localhost:5173/draggable
```
## 📸 功能演示
### 基本操作流程
1. **选择页面** - 在左侧"页面管理"中选择要编辑的Vue文件
2. **拖拽组件** - 从"设计组件列表"拖拽组件到设计区域
3. **调整位置** - 使用↑↓键切换层级,选择放置方向
4. **编辑属性** - 点击组件,在"元数据"面板编辑属性
5. **删除元素** - 右键菜单、Del键或点击删除图标
### 键盘快捷键
| 快捷键 | 功能 |
|--------|------|
| ↑ | 选择父级元素 |
| ↓ | 选择子级元素 |
| Delete | 删除选中元素 |
| Esc | 取消拖拽操作 |
## 📁 项目结构
```
fauto-design/
├── draggable-panels/ # 前端项目
│ ├── src/
│ │ ├── fauto/ # 设计器核心
│ │ │ ├── components/ # UI组件
│ │ │ ├── materials/ # 物料组件
│ │ │ ├── designComponents/ # 设计组件库
│ │ │ ├── plugins/ # 插件系统
│ │ │ └── stores/ # 状态管理
│ │ └── views/ # 示例页面
│ └── vite.config.ts
└── vue-template-service/ # 后端服务
└── src/
├── index.js # API入口
└── services/
└── templateService.js # 模板解析
```
## 🔌 设计组件
内置设计组件:
| 组件 | 描述 |
|------|------|
| 输入框 (TextInput) | 文本输入表单组件 |
| 单选器 (RadioSelect) | 单选按钮组组件 |
| 表格 (GridTable) | 数据表格组件 |
### 添加自定义设计组件
1.`draggable-panels/src/fauto/designComponents/` 创建目录
2. 添加配置文件 `index.json`
```json
{
"id": "MyComponent",
"name": "我的组件",
"icon": "🎯",
"description": "组件描述",
"defaultSpan": 12,
"metadata": {
"span": {
"label": "宽度",
"type": "number",
"min": 1,
"max": 24,
"target": "el-col",
"attr": ":span"
}
}
}
```
3. 添加模板文件 `template.html`
```html
<el-col :span="12">
<div class="design-component" data-component="我的组件">
<!-- 组件内容 -->
</div>
</el-col>
```
## 📋 Vue页面规范
设计器要求Vue页面遵循以下结构
```vue
<template>
<!-- 第一层级必须且只能有一个el-row -->
<el-row :gutter="20">
<el-col :span="12">
<!-- 设计组件需要data-component属性 -->
<div class="design-component" data-component="组件名">
<!-- 内容 -->
</div>
</el-col>
</el-row>
</template>
```
## 🔗 API文档
后端服务运行在 `http://localhost:3001`
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/move-element` | POST | 移动元素 |
| `/api/insert-element` | POST | 插入新元素 |
| `/api/delete-element` | POST | 删除元素 |
| `/api/element-tree` | GET | 获取页面结构 |
| `/api/component-props` | GET | 获取组件属性 |
| `/api/update-props` | POST | 更新组件属性 |
## 📚 文档
- [项目上下文](./项目上下文.md) - AI协作上下文
- [项目设计文档](./项目设计文档.md) - 详细技术设计
- [开发指南](./DEVELOPMENT.md) - 开发规范与扩展指南
## 🛠️ 技术栈
**前端**
- Vue 3.5 (Composition API)
- TypeScript
- Vite 7.3
- Pinia 3.0
- Element Plus
**后端**
- Node.js
- Express
- @vue/compiler-sfc
- @vue/compiler-dom
## 👤 作者
- **QQ**: 1040079213
欢迎交流讨论如有问题或建议请通过QQ联系我。
## 🤝 致谢
本项目代码由 **Qoder (AI Assistant)** 编写实现,基于作者的创意设计和需求规范。
这是一次人机协作的实践:
- 💡 **创意与架构**: 项目作者
- 💻 **代码实现**: Qoder
- 🔄 **迭代优化**: 共同完成
感谢AI时代让这种协作成为可能。
## 📄 License
[MIT License](./LICENSE)
Copyright (c) 2026 Fauto Design

View File

@@ -1,5 +1,21 @@
# Vue 3 + TypeScript + Vite
# Draggable Panels - 前端项目
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
这是 Fauto Design 的前端项目,基于 Vue 3 + TypeScript + Vite 构建。
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
## 快速开始
```bash
npm install
npm run dev
```
访问:`http://localhost:5173/draggable`
## 详细文档
请参阅项目根目录的文档:
- [README.md](../README.md) - 项目介绍
- [项目上下文.md](../项目上下文.md) - AI协作上下文
- [项目设计文档.md](../项目设计文档.md) - 技术设计
- [DEVELOPMENT.md](../DEVELOPMENT.md) - 开发指南

View File

@@ -3,20 +3,20 @@
"leftPanel": {
"id": "left",
"tabs": [
{
"id": "6hfm9ux",
"title": "页面管理",
"content": "新窗口内容",
"materialId": "PageManager"
},
{
"id": "up60643",
"title": "设计组件列表",
"content": "新窗口内容",
"materialId": "DesignComponentList"
},
{
"id": "78ikdz5",
"title": "测试组件B",
"content": "新窗口内容",
"materialId": "TestWidget2"
}
],
"activeTabId": "up60643"
"activeTabId": "6hfm9ux"
},
"centerPanel": {
"id": "center",
@@ -28,7 +28,7 @@
"materialId": "DesignCenter"
},
{
"id": "rdp9iuv",
"id": "m9fv3nb",
"title": "测试组件A",
"content": "新窗口内容",
"materialId": "TestWidget1"
@@ -40,112 +40,20 @@
"id": "right",
"tabs": [
{
"id": "vrh9bl2",
"title": "数据表格",
"id": "09w17vo",
"title": "数据",
"content": "新窗口内容",
"materialId": "DataTable",
"materialState": {
"data": [
{
"property": "项目名称",
"value": "1111"
"materialId": "DataTable"
},
{
"property": "框架",
"value": "9999"
},
{
"property": "语言",
"value": "TypeScript"
},
{
"property": "构建工具",
"value": "Vite"
},
{
"property": "状态管理",
"value": "Pinia"
},
{
"property": "版本",
"value": "1.0.0"
},
{
"property": "作者",
"value": "Developer"
},
{
"property": "许可证",
"value": "MIT"
}
]
}
},
{
"id": "mxfx11j",
"title": "树形展示器",
"id": "vihlzl5",
"title": "结构",
"content": "新窗口内容",
"materialId": "TreeViewer",
"materialState": {
"treeData": [
{
"id": "1",
"label": "项目根目录",
"expanded": true,
"children": [
{
"id": "1-1",
"label": "src",
"expanded": true,
"children": [
{
"id": "1-1-2",
"label": "stores"
},
{
"id": "1-1-4",
"label": "App.vue"
},
{
"id": "1-1-5",
"label": "main.ts"
},
{
"id": "1-1-1",
"label": "components"
}
]
},
{
"id": "1-1-3",
"label": "types"
},
{
"id": "1-2",
"label": "public",
"children": [
{
"id": "1-3",
"label": "package.json"
},
{
"id": "1-2-1",
"label": "favicon.ico"
}
]
},
{
"id": "1-4",
"label": "vite.config.ts"
}
]
}
]
}
"materialId": "TreeViewer"
}
],
"activeTabId": "vrh9bl2"
"activeTabId": "09w17vo"
}
},
"lastUpdated": "2025-12-20T14:50:20.652Z"
"lastUpdated": "2026-01-20T13:52:38.363Z"
}

View File

@@ -52,8 +52,20 @@
"列3"
]
}
},
{
"id": "jy87mdv",
"componentId": "RadioSelect",
"name": "单选器 2",
"props": {
"options": [
"选项1",
"选项2",
"选项3"
]
}
}
],
"selectedId": "xazr6j9",
"lastUpdated": "2025-12-20T14:50:43.062Z"
"selectedId": "jy87mdv",
"lastUpdated": "2025-12-22T14:42:41.050Z"
}

View File

@@ -8,6 +8,7 @@
"name": "draggable-panels",
"version": "0.0.0",
"dependencies": {
"element-plus": "^2.8.8",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4",
@@ -68,6 +69,24 @@
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -510,12 +529,48 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
"integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -838,6 +893,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/node": {
"version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
@@ -848,6 +918,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
@@ -1070,6 +1146,94 @@
}
}
},
"node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/alien-signals": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz",
@@ -1077,6 +1241,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@@ -1107,6 +1277,37 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/element-plus": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
"integrity": "sha512-qjxS+SBChvqCl6lU6ShiliLMN6WqFHiXQENYbAY3GKNflG+FS3jqn8JmQq0CBZq4koFqsi95NT1M6SL4whZfrA==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "^10.11.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.19",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/entities": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
@@ -1218,6 +1419,29 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1227,6 +1451,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -1258,6 +1488,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"element-plus": "^2.8.8",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4",

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useInteractionStore, useDragStore } from '../plugins'
const interactionStore = useInteractionStore()
const dragStore = useDragStore()
const currentTime = ref('')
let timer: number | null = null
@@ -17,6 +21,57 @@ const updateTime = () => {
})
}
// 交互状态显示文本
const interactionText = computed(() => {
return interactionStore.formatInteraction(interactionStore.lastInteraction)
})
// 当前悬停的元素信息
const hoverInfo = computed(() => {
const target = interactionStore.hoverTarget
if (!target) return null
const typeName = interactionStore.getTypeName(target.type)
if (target.type === 'dc') {
return `悬停: ${typeName} [${target.componentId}]`
}
return `悬停: ${typeName} [${target.path}]`
})
// 当前选中的元素信息
const selectedInfo = computed(() => {
const target = interactionStore.selectedTarget
if (!target) return null
const typeName = interactionStore.getTypeName(target.type)
if (target.type === 'dc') {
return `选中: ${typeName} [${target.componentId}]`
}
return `选中: ${typeName} [${target.path}]`
})
// 拖拽状态信息
const dragInfo = computed(() => {
if (!dragStore.isDragging) return null
const source = dragStore.dragSource
if (!source) return null
if (source.type === 'design-component') {
return `拖拽: ${source.componentName}`
}
return `拖拽: ${source.path}`
})
// 删除不再使用的代码
// const dropTargetInfo = ...
// const hierarchyInfo = ...
// 最后一次拖放记录
const lastDropInfo = computed(() => {
return dragStore.formatDropRecord(dragStore.lastDropRecord)
})
onMounted(() => {
updateTime()
timer = window.setInterval(updateTime, 1000)
@@ -30,8 +85,42 @@ onUnmounted(() => {
</script>
<template>
<footer class="app-footer">
<div class="footer-left"></div>
<footer class="app-footer" :class="{ 'is-dragging': dragStore.isDragging }">
<div class="footer-left">
<!-- 拖拽状态显示 -->
<template v-if="dragStore.isDragging">
<span class="info-item drag-info">
🎯 {{ dragInfo }}
</span>
<span v-if="hoverInfo" class="info-item hover-info">
👁 {{ hoverInfo }}
</span>
</template>
<!-- 普通状态显示 -->
<template v-else>
<span v-if="hoverInfo" class="info-item hover-info">
👁 {{ hoverInfo }}
</span>
<span v-if="selectedInfo" class="info-item selected-info">
{{ selectedInfo }}
</span>
<span v-if="!hoverInfo && !selectedInfo" class="info-item idle-info">
🎯 就绪 | 悬停或点击元素查看信息
</span>
</template>
</div>
<div class="footer-center">
<!-- 拖放记录 -->
<span v-if="lastDropInfo" class="drop-record">
📋 拖放: {{ lastDropInfo }}
</span>
<span v-else-if="interactionStore.lastInteraction" class="last-action">
最近: {{ interactionText }}
</span>
</div>
<div class="footer-right">
<span class="time">{{ currentTime }}</span>
</div>
@@ -47,10 +136,19 @@ onUnmounted(() => {
justify-content: space-between;
padding: 0 12px;
flex-shrink: 0;
gap: 20px;
}
.footer-left {
flex: 1;
display: flex;
align-items: center;
gap: 16px;
}
.footer-center {
display: flex;
align-items: center;
}
.footer-right {
@@ -58,6 +156,62 @@ onUnmounted(() => {
align-items: center;
}
.info-item {
color: white;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.hover-info {
color: #ffffc0;
}
.selected-info {
color: #90ee90;
}
.idle-info {
color: rgba(255, 255, 255, 0.7);
}
.last-action {
color: rgba(255, 255, 255, 0.8);
font-size: 11px;
background: rgba(0, 0, 0, 0.2);
padding: 2px 8px;
border-radius: 3px;
}
.drop-record {
color: #90ee90;
font-size: 11px;
background: rgba(0, 0, 0, 0.3);
padding: 2px 8px;
border-radius: 3px;
font-weight: 500;
}
/* 拖拽状态样式 */
.app-footer.is-dragging {
background: #e6a23c;
}
.drag-info {
color: #fff;
font-weight: 500;
}
.target-info {
color: #ffffc0;
}
.direction-info {
color: #90ee90;
font-weight: 500;
}
.time {
color: white;
font-size: 12px;

View File

@@ -1,9 +1,42 @@
{
"id": "GridTable",
"name": "表格",
"icon": "📊",
"description": "用于展示数据的表格组件",
"props": {
"rows": 3,
"columns": 3,
"headers": ["列1", "列2", "列3"]
"template": "template.html",
"defaultSpan": 24,
"metadata": {
"span": {
"label": "宽度",
"type": "number",
"min": 1,
"max": 24,
"target": "el-col",
"attr": ":span"
},
"size": {
"label": "尺寸",
"type": "select",
"options": ["", "medium", "small", "mini"],
"target": "el-table",
"attr": "size"
},
"stripe": {
"label": "斑马纹",
"type": "boolean",
"target": "el-table",
"attr": "stripe"
},
"border": {
"label": "边框",
"type": "boolean",
"target": "el-table",
"attr": "border"
},
"columns": {
"label": "列配置",
"type": "columns",
"target": "el-table-column"
}
}
}

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
rows?: number
columns?: number
headers?: string[]
}>()
const tableRows = computed(() => props.rows || 3)
const tableCols = computed(() => props.columns || 3)
const tableHeaders = computed(() => props.headers || [])
</script>
<template>
<div class="design-grid-table">
<table>
<thead>
<tr>
<th v-for="(header, i) in tableHeaders" :key="i">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableRows" :key="row">
<td v-for="col in tableCols" :key="col">
单元格 {{ row }}-{{ col }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.design-grid-table {
padding: 8px;
background: #2d2d2d;
border-radius: 4px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th, td {
padding: 6px 8px;
border: 1px solid #3c3c3c;
text-align: left;
color: #cccccc;
}
th {
background: #1e1e1e;
font-weight: 500;
}
td {
background: #252526;
}
</style>

View File

@@ -0,0 +1,9 @@
<el-col :span="24">
<div class="design-component design-grid-table" data-component="表格">
<el-table :data="tableData" border style="width: 100%">
<el-table-column prop="col1" label="列1" />
<el-table-column prop="col2" label="列2" />
<el-table-column prop="col3" label="列3" />
</el-table>
</div>
</el-col>

View File

@@ -1,7 +1,49 @@
{
"id": "RadioSelect",
"name": "单选器",
"icon": "⭕",
"description": "用于选择单个选项的表单组件",
"props": {
"options": ["选项1", "选项2", "选项3"]
"template": "template.html",
"defaultSpan": 12,
"metadata": {
"span": {
"label": "宽度",
"type": "number",
"min": 1,
"max": 24,
"target": "el-col",
"attr": ":span"
},
"label": {
"label": "标签",
"type": "text",
"target": "el-form-item",
"attr": "label"
},
"size": {
"label": "尺寸",
"type": "select",
"options": ["", "medium", "small", "mini"],
"target": "el-radio-group",
"attr": "size"
},
"disabled": {
"label": "禁用",
"type": "boolean",
"target": "el-radio-group",
"attr": "disabled"
},
"fill": {
"label": "填充色",
"type": "color",
"target": "el-radio-group",
"attr": "fill"
},
"textColor": {
"label": "文字颜色",
"type": "color",
"target": "el-radio-group",
"attr": "text-color"
}
}
}

View File

@@ -1,75 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
options?: string[]
}>()
const selected = ref<string | null>(null)
</script>
<template>
<div class="design-radio-select">
<div
v-for="option in (options || [])"
:key="option"
class="radio-item"
@click="selected = option"
>
<span class="radio-circle" :class="{ active: selected === option }">
<span class="radio-dot" v-if="selected === option"></span>
</span>
<span class="radio-label">{{ option }}</span>
</div>
</div>
</template>
<style scoped>
.design-radio-select {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
background: #2d2d2d;
border-radius: 4px;
}
.radio-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px;
cursor: pointer;
border-radius: 4px;
}
.radio-item:hover {
background: #3c3c3c;
}
.radio-circle {
width: 16px;
height: 16px;
border: 2px solid #555;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.radio-circle.active {
border-color: #007acc;
}
.radio-dot {
width: 8px;
height: 8px;
background: #007acc;
border-radius: 50%;
}
.radio-label {
color: #cccccc;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,11 @@
<el-col :span="12">
<div class="design-component design-radio-select" data-component="单选器">
<el-form-item label="单选选择">
<el-radio-group v-model="radioValue">
<el-radio label="选项1" value="1" />
<el-radio label="选项2" value="2" />
<el-radio label="选项3" value="3" />
</el-radio-group>
</el-form-item>
</div>
</el-col>

View File

@@ -1,9 +1,51 @@
{
"name": "文本输入框",
"id": "TextInput",
"name": "输入框",
"icon": "✏️",
"description": "用于输入文本的表单组件",
"props": {
"label": "标签名称",
"width": 200,
"maxLength": 100
"template": "template.html",
"defaultSpan": 12,
"metadata": {
"span": {
"label": "宽度",
"type": "number",
"min": 1,
"max": 24,
"target": "el-col",
"attr": ":span"
},
"label": {
"label": "标签",
"type": "text",
"target": "el-form-item",
"attr": "label"
},
"type": {
"label": "类型",
"type": "select",
"options": ["", "text", "textarea"],
"target": "el-input",
"attr": "type"
},
"placeholder": {
"label": "占位文本",
"type": "text",
"target": "el-input",
"attr": "placeholder"
},
"minlength": {
"label": "最小长度",
"type": "number",
"min": 0,
"target": "el-input",
"attr": "minlength"
},
"maxlength": {
"label": "最大长度",
"type": "number",
"min": 0,
"target": "el-input",
"attr": "maxlength"
}
}
}

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
defineProps<{
label?: string
width?: number
maxLength?: number
}>()
</script>
<template>
<div class="design-text-input" :style="{ width: width + 'px' }">
<label class="input-label">{{ label }}</label>
<input
type="text"
class="input-field"
:maxlength="maxLength"
placeholder="请输入..."
/>
</div>
</template>
<style scoped>
.design-text-input {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: #2d2d2d;
border-radius: 4px;
}
.input-label {
color: #cccccc;
font-size: 12px;
}
.input-field {
padding: 6px 8px;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 4px;
color: #d4d4d4;
font-size: 13px;
outline: none;
}
.input-field:focus {
border-color: #007acc;
}
</style>

View File

@@ -0,0 +1,7 @@
<el-col :span="12">
<div class="design-component design-text-input" data-component="输入框">
<el-form-item label="文本输入">
<el-input v-model="inputValue" placeholder="请输入内容"></el-input>
</el-form-item>
</div>
</el-col>

View File

@@ -1,17 +1,4 @@
{
"name": "数据表格",
"description": "展示属性和值的简单表格组件",
"props": {
"columns": ["属性", "值"],
"data": [
{ "property": "项目名称", "value": "draggable-panels" },
{ "property": "框架", "value": "Vue 3" },
{ "property": "语言", "value": "TypeScript" },
{ "property": "构建工具", "value": "Vite" },
{ "property": "状态管理", "value": "Pinia" },
{ "property": "版本", "value": "1.0.0" },
{ "property": "作者", "value": "Developer" },
{ "property": "许可证", "value": "MIT" }
]
}
"name": "数据",
"description": "展示和编辑设计组件的属性"
}

View File

@@ -1,125 +1,224 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, watch } from 'vue'
import { useDesignStore } from '../../stores/designStore'
import { useVueFileStore } from '../../stores/vueFileStore'
import config from './index.json'
const TEMPLATE_SERVICE_URL = 'http://localhost:3001'
const designStore = useDesignStore()
const vueFileStore = useVueFileStore()
// 编辑状态
const editingCell = ref<{ key: string } | null>(null)
const editValue = ref('')
// 本地编辑值
const localValues = ref<Record<string, any>>({})
// 获取选中组件的属性列表
const propertyList = computed(() => {
const comp = designStore.selectedComponent
if (!comp) return []
// 更新中状态
const updating = ref(false)
return Object.entries(comp.props).map(([key, value]) => ({
key,
value: formatValue(value),
rawValue: value
// 初始化本地值
watch(() => designStore.selectedComponent, (comp) => {
if (comp) {
localValues.value = { ...comp.props }
// 提取 span 值
if (comp.props.span !== undefined) {
localValues.value.span = comp.props.span
}
} else {
localValues.value = {}
}
}, { immediate: true, deep: true })
// 更新属性
const updateProp = async (key: string, value: any) => {
if (!designStore.selectedComponent || !vueFileStore.selectedFilePath) return
updating.value = true
try {
const updates: Record<string, any> = {}
// 根据元数据 schema 构建更新
const schema = designStore.selectedMetadataSchema
if (schema && schema[key]) {
const field = schema[key]
if (key === 'span') {
updates.span = value
} else {
updates[`${field.target}:${field.attr}`] = value
}
}
const response = await fetch(`${TEMPLATE_SERVICE_URL}/api/update-props`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pagePath: vueFileStore.selectedFilePath,
elementPath: designStore.selectedComponent.path,
updates
})
})
const result = await response.json()
if (result.success) {
console.log('[元数据] 更新成功:', key, value)
// 触发页面刷新
window.dispatchEvent(new CustomEvent('vue-template-updated', {
detail: { pagePath: vueFileStore.selectedFilePath }
}))
})
// 格式化显示值
const formatValue = (value: any): string => {
if (Array.isArray(value)) {
return value.join(', ')
} else {
console.error('[元数据] 更新失败:', result.error)
}
return String(value)
}
// 开始编辑
const startEdit = (key: string, value: any) => {
editingCell.value = { key }
editValue.value = formatValue(value)
}
// 完成编辑
const finishEdit = () => {
if (editingCell.value && designStore.selectedId) {
const { key } = editingCell.value
// 尝试解析值
let newValue: any = editValue.value
// 如果原始值是数组,尝试解析为数组
const comp = designStore.selectedComponent
if (comp && Array.isArray(comp.props[key])) {
newValue = editValue.value.split(',').map(s => s.trim())
} else if (!isNaN(Number(editValue.value)) && editValue.value !== '') {
newValue = Number(editValue.value)
}
designStore.updateComponentProps(designStore.selectedId, key, newValue)
editingCell.value = null
} catch (error) {
console.error('[元数据] 请求失败:', error)
} finally {
updating.value = false
}
}
// 取消编辑
const cancelEdit = () => {
editingCell.value = null
// 输入变化处理
const handleInputChange = (key: string, event: Event) => {
const target = event.target as HTMLInputElement
let value: any = target.value
const schema = designStore.selectedMetadataSchema
if (schema && schema[key]) {
if (schema[key].type === 'number') {
value = parseInt(value) || 0
} else if (schema[key].type === 'boolean') {
value = target.checked
}
}
localValues.value[key] = value
updateProp(key, value)
}
// 检查是否正在编辑
const isEditing = (key: string) => {
return editingCell.value?.key === key
// 选择变化处理
const handleSelectChange = (key: string, event: Event) => {
const target = event.target as HTMLSelectElement
localValues.value[key] = target.value
updateProp(key, target.value)
}
// 布尔值变化处理
const handleBooleanChange = (key: string, event: Event) => {
const target = event.target as HTMLInputElement
localValues.value[key] = target.checked
updateProp(key, target.checked)
}
// 色彩变化处理
const handleColorChange = (key: string, event: Event) => {
const target = event.target as HTMLInputElement
localValues.value[`${designStore.selectedMetadataSchema?.[key]?.target}:${designStore.selectedMetadataSchema?.[key]?.attr}`] = target.value
updateProp(key, target.value)
}
</script>
<template>
<div class="data-table">
<div class="table-header">
<div class="metadata-editor">
<div class="editor-header">
<span class="title">{{ config.name }}</span>
<span class="hint" v-if="designStore.selectedComponent">
{{ designStore.selectedComponent.name }}
{{ designStore.selectedComponent.componentName }}
</span>
<span class="hint" v-else>请选择组件</span>
</div>
<div class="table-body">
<table class="table" v-if="propertyList.length > 0">
<thead>
<tr>
<th>属性</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="prop in propertyList" :key="prop.key">
<td class="prop-key">{{ prop.key }}</td>
<td @dblclick="startEdit(prop.key, prop.rawValue)">
<input
v-if="isEditing(prop.key)"
v-model="editValue"
class="edit-input"
@blur="finishEdit"
@keyup.enter="finishEdit"
@keyup.escape="cancelEdit"
autofocus
/>
<span v-else class="cell-value">{{ prop.value }}</span>
</td>
</tr>
</tbody>
</table>
<div class="editor-body">
<!-- 有选中组件且有元数据 -->
<div v-if="designStore.selectedComponent && designStore.selectedMetadataSchema" class="props-list">
<div
v-for="(field, key) in designStore.selectedMetadataSchema"
:key="key"
class="prop-item"
>
<label class="prop-label">{{ field.label }}</label>
<!-- 数字输入 -->
<input
v-if="field.type === 'number'"
type="number"
class="prop-input"
:min="field.min"
:max="field.max"
:value="localValues[key] ?? localValues.span"
@change="handleInputChange(key as string, $event)"
:disabled="updating"
/>
<!-- 文本输入 -->
<input
v-else-if="field.type === 'text'"
type="text"
class="prop-input"
:value="localValues[`${field.target}:${field.attr}`] || ''"
@change="handleInputChange(key as string, $event)"
:disabled="updating"
/>
<!-- 单选下拉 -->
<select
v-else-if="field.type === 'select'"
class="prop-select"
:value="localValues[`${field.target}:${field.attr}`] || ''"
@change="handleSelectChange(key as string, $event)"
:disabled="updating"
>
<option v-for="opt in field.options" :key="opt" :value="opt">
{{ opt || '默认' }}
</option>
</select>
<!-- 布尔开关 -->
<label v-else-if="field.type === 'boolean'" class="prop-checkbox">
<input
type="checkbox"
:checked="localValues[`${field.target}:${field.attr}`] === 'true' || localValues[`${field.target}:${field.attr}`] === true"
@change="handleBooleanChange(key as string, $event)"
:disabled="updating"
/>
<span class="checkbox-text">{{ localValues[`${field.target}:${field.attr}`] ? '是' : '否' }}</span>
</label>
<!-- 颜色选择器 -->
<div v-else-if="field.type === 'color'" class="color-picker-wrapper">
<input
type="color"
class="prop-color"
:value="localValues[`${field.target}:${field.attr}`] || '#409EFF'"
@input="handleColorChange(key as string, $event)"
:disabled="updating"
/>
<span class="color-value">{{ localValues[`${field.target}:${field.attr}`] || '#409EFF' }}</span>
</div>
<!-- 列配置暂不支持 -->
<span v-else-if="field.type === 'columns'" class="prop-hint">
(暂不支持编辑)
</span>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-tip">
<div class="empty-icon">📋</div>
<div>暂无属性</div>
<div class="empty-hint">先在树形或设计中心选择一个组件</div>
<div>暂无元数据</div>
<div class="empty-hint">点击设计中心或结构树中的组件</div>
</div>
</div>
</div>
</template>
<style scoped>
.data-table {
.metadata-editor {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.table-header {
.editor-header {
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
@@ -135,74 +234,94 @@ const isEditing = (key: string) => {
}
.hint {
color: #007acc;
color: #4fc3f7;
font-size: 11px;
}
.table-body {
.editor-body {
flex: 1;
padding: 12px;
overflow: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
.props-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.table th,
.table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #3c3c3c;
.prop-item {
display: flex;
align-items: center;
gap: 12px;
}
.table th {
background: #2d2d2d;
color: #cccccc;
font-weight: 500;
}
.table td {
color: #d4d4d4;
cursor: pointer;
position: relative;
}
.table td.prop-key {
.prop-label {
min-width: 80px;
color: #9cdcfe;
font-family: monospace;
cursor: default;
font-size: 12px;
}
.table td:hover {
background: #2a2d2e;
}
.table tbody tr:hover {
background: #2a2d2e;
}
.cell-value {
display: block;
min-height: 20px;
}
.edit-input {
width: 100%;
padding: 4px 8px;
.prop-input,
.prop-select {
flex: 1;
padding: 6px 10px;
background: #3c3c3c;
border: 1px solid #007acc;
border-radius: 3px;
color: #ffffff;
font-size: 13px;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
font-size: 12px;
outline: none;
}
.edit-input:focus {
.prop-input:focus,
.prop-select:focus {
border-color: #007acc;
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.3);
}
.prop-input:disabled,
.prop-select:disabled {
opacity: 0.6;
}
.prop-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-text {
color: #ccc;
font-size: 12px;
}
.prop-color {
width: 40px;
height: 30px;
padding: 2px;
background: #3c3c3c;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
}
.color-picker-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
.color-value {
color: #ccc;
font-size: 12px;
font-family: monospace;
}
.prop-hint {
color: #666;
font-size: 11px;
font-style: italic;
}
.empty-tip {

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useDragStore } from '../../plugins'
const dragStore = useDragStore()
// 鼠标位置
const mouseX = ref(0)
const mouseY = ref(0)
// 预览样式
const previewStyle = computed(() => ({
position: 'fixed',
left: `${mouseX.value + 15}px`,
top: `${mouseY.value + 15}px`,
pointerEvents: 'none',
zIndex: 9999
}))
// 拖拽源显示文本
const sourceText = computed(() => {
const source = dragStore.dragSource
if (!source) return ''
if (source.type === 'design-component') {
return source.componentName || source.componentId || '组件'
}
return source.path || '元素'
})
// 拖拽源类型图标
const sourceIcon = computed(() => {
const source = dragStore.dragSource
if (!source) return '📦'
if (source.type === 'design-component') {
return '📦'
}
return source.elementType === 'er' ? '⬛' : '◻️'
})
// 鼠标移动处理
const handleMouseMove = (e: MouseEvent) => {
mouseX.value = e.clientX
mouseY.value = e.clientY
}
// 监听拖拽状态变化控制body class
watch(() => dragStore.isDragging, (isDragging) => {
if (isDragging) {
document.body.classList.add('is-dragging')
} else {
document.body.classList.remove('is-dragging')
}
})
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove)
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.body.classList.remove('is-dragging')
})
</script>
<template>
<Teleport to="body">
<div
v-if="dragStore.isDragging && dragStore.dragSource"
class="drag-preview"
:style="previewStyle"
>
<div class="preview-icon">{{ sourceIcon }}</div>
<div class="preview-content">
<div class="preview-label">
{{ dragStore.dragSource.type === 'design-component' ? '设计组件' : '画布元素' }}
</div>
<div class="preview-name">{{ sourceText }}</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.drag-preview {
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.9);
border: 2px solid #409eff;
border-radius: 8px;
padding: 10px 14px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: dragPreviewAppear 0.15s ease-out;
max-width: 200px;
}
@keyframes dragPreviewAppear {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.preview-icon {
font-size: 24px;
flex-shrink: 0;
}
.preview-content {
flex: 1;
min-width: 0;
}
.preview-label {
font-size: 10px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-name {
font-size: 13px;
color: #fff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useDragStore } from '../../plugins'
import type { DropDirection } from '../../plugins'
const dragStore = useDragStore()
// 拖放区域位置
const zoneStyle = ref<Record<string, string>>({})
// 计算拖放区域位置
const updateZonePosition = () => {
if (!dragStore.selectedNode?.element) {
zoneStyle.value = {}
return
}
const rect = dragStore.selectedNode.element.getBoundingClientRect()
zoneStyle.value = {
position: 'fixed',
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`
}
}
// 监听选中节点变化
watch(() => dragStore.selectedNode, () => {
updateZonePosition()
}, { immediate: true })
// 当前选中节点的拖放方向选项
const directions = computed(() => dragStore.getDropDirections)
// 是否是行(上下布局)
const isRowLayout = computed(() => {
return dragStore.selectedNode?.type === 'er'
})
// 是否是跨类型拖放
const isCrossType = computed(() => dragStore.isCrossTypeDrop)
// 获取方向文本
const getDirectionText = (direction: DropDirection): string => {
const texts: Record<DropDirection, string> = {
'top': '移动至上方',
'bottom': '移动至下方',
'left': '移动至左侧',
'right': '移动至右侧',
'inside': '放入其中'
}
return texts[direction]
}
// 获取方向图标
const getDirectionIcon = (direction: DropDirection): string => {
const icons: Record<DropDirection, string> = {
'top': '⬆',
'bottom': '⬇',
'left': '⬅',
'right': '➡',
'inside': '⬇️⤵️' // 放入图标
}
return icons[direction]
}
</script>
<template>
<Teleport to="body">
<div
v-if="dragStore.isDragging && dragStore.selectedNode"
class="drop-zone-container"
:class="{
'is-row': isRowLayout && !isCrossType,
'is-col': !isRowLayout && !isCrossType,
'is-inside': isCrossType
}"
:style="zoneStyle"
>
<div
v-for="direction in directions"
:key="direction"
class="drop-zone"
:class="[
`zone-${direction}`,
{ 'is-active': dragStore.hoverDirection === direction }
]"
>
<span class="zone-icon">{{ getDirectionIcon(direction) }}</span>
<span class="zone-text">{{ getDirectionText(direction) }}</span>
</div>
</div>
</Teleport>
</template>
<style scoped>
.drop-zone-container {
pointer-events: none; /* 容器和所有子元素都不拦截事件 */
z-index: 100;
display: flex;
/* 不再设置 position, top, left 等,直接使用 :style 绑定的值 */
}
/* 行布局:上下排列 */
.drop-zone-container.is-row {
flex-direction: column;
}
/* 列布局:左右排列 */
.drop-zone-container.is-col {
flex-direction: row;
}
.drop-zone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: rgba(64, 158, 255, 0.1);
border: 2px dashed rgba(64, 158, 255, 0.5);
transition: all 0.2s;
pointer-events: none; /* 不拦截事件,让底层元素可以接收 mouseenter */
cursor: pointer;
}
/* 上方区域 */
.zone-top {
border-bottom: 2px solid rgba(64, 158, 255, 0.8);
}
/* 下方区域 */
.zone-bottom {
border-top: 2px solid rgba(64, 158, 255, 0.8);
}
/* 左侧区域 */
.zone-left {
border-right: 2px solid rgba(64, 158, 255, 0.8);
}
/* 右侧区域 */
.zone-right {
border-left: 2px solid rgba(64, 158, 255, 0.8);
}
/* 放入内部区域 */
.drop-zone-container.is-inside {
flex-direction: column;
}
.zone-inside {
background: rgba(103, 194, 58, 0.15);
border: 3px dashed rgba(103, 194, 58, 0.6);
border-radius: 8px;
}
.zone-inside.is-active {
background: rgba(103, 194, 58, 0.3);
border-color: rgba(103, 194, 58, 0.9);
}
.zone-inside .zone-icon,
.zone-inside .zone-text {
color: #67c23a;
}
.zone-inside.is-active .zone-icon,
.zone-inside.is-active .zone-text {
color: #85ce61;
}
.drop-zone:hover,
.drop-zone.is-active {
background: rgba(64, 158, 255, 0.25);
border-color: rgba(64, 158, 255, 0.8);
}
.zone-icon {
font-size: 24px;
color: #409eff;
}
.zone-text {
font-size: 12px;
color: #409eff;
font-weight: 500;
}
.drop-zone:hover .zone-icon,
.drop-zone:hover .zone-text,
.drop-zone.is-active .zone-icon,
.drop-zone.is-active .zone-text {
color: #66b1ff;
}
</style>

View File

@@ -0,0 +1,574 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { useInteractionStore, useDragStore, generateElementPath } from '../../plugins'
import type { ElementType, InteractionTarget } from '../../plugins'
import { useVueFileStore } from '../../stores/vueFileStore'
import { useDesignStore } from '../../stores/designStore'
import DropZone from './DropZone.vue'
import DragPreview from './DragPreview.vue'
const props = defineProps<{
component: any
}>()
const interactionStore = useInteractionStore()
const dragStore = useDragStore()
const vueFileStore = useVueFileStore()
const designStore = useDesignStore()
const containerRef = ref<HTMLElement | null>(null)
// 右键菜单状态
const contextMenu = ref<{ x: number, y: number, path: string, type: ElementType } | null>(null)
// 存储所有绑定的事件清理函数
const cleanupFunctions: (() => void)[] = []
// MutationObserver 用于监听DOM变化
let observer: MutationObserver | null = null
// 当前选中的节点用于显示DropZone的位置
const selectedNodeRect = computed(() => {
if (!dragStore.selectedNode?.element) return null
return dragStore.selectedNode.element.getBoundingClientRect()
})
/**
* 为元素绑定交互事件
*/
const bindElementEvents = (element: HTMLElement, type: ElementType, path: string) => {
// 跳过已经绑定过的元素
if (element.hasAttribute('data-fauto-bindend')) return null
const target: InteractionTarget = {
type,
path,
element
}
// 设置数据属性
element.setAttribute('data-path', path)
element.setAttribute('data-type', type)
element.setAttribute('data-fauto-bindend', 'true')
// 添加可交互样式类
element.classList.add('fauto-interactive')
// 鼠标悬停
const handleMouseEnter = (e: MouseEvent) => {
e.stopPropagation()
// 更新交互store的悬停状态无论是否拖拽
interactionStore.onHover(target)
if (!dragStore.isDragging) {
element.classList.add('fauto-hover')
} else {
// 拖拽时移动到不同元素,进入目标选择阶段
// 检查是否是不同的元素(不是源元素或其父级)
const sourcePath = dragStore.confirmedSource?.path || dragStore.dragSource?.path
if (sourcePath && path !== sourcePath && !path.startsWith(sourcePath)) {
dragStore.enterTargetPhase(element)
updateSelectedHighlight()
}
}
}
// 鼠标离开
const handleMouseLeave = (e: MouseEvent) => {
e.stopPropagation()
if (!dragStore.isDragging) {
interactionStore.onLeave()
element.classList.remove('fauto-hover')
}
}
// 鼠标点击
const handleClick = (e: MouseEvent) => {
e.stopPropagation()
// 如果正在拖拽,不处理点击
if (dragStore.isDragging) return
interactionStore.onClick(target)
// 移除其他元素的选中状态
document.querySelectorAll('.fauto-selected').forEach(el => {
el.classList.remove('fauto-selected')
})
element.classList.add('fauto-selected')
// 检查是否是叶子节点(设计组件)
if (type === 'ec') {
const componentDiv = element.querySelector('[data-component]')
if (componentDiv && vueFileStore.selectedFilePath) {
const componentName = componentDiv.getAttribute('data-component')
if (componentName) {
designStore.selectComponent(path, componentName, vueFileStore.selectedFilePath)
// 触发自定义事件,通知结构树同步选中
window.dispatchEvent(new CustomEvent('design-component-selected', {
detail: { path, componentName }
}))
}
}
}
}
// 鼠标按下(画布内元素拖拽)
const handleMouseDown = (e: MouseEvent) => {
e.stopPropagation()
// 如果已经有拖拽源,不处理
if (dragStore.isDragging) return
// 开始画布内元素拖拽,传入元素引用以立即构建层级列表
dragStore.startDragFromCanvas(path, type, element)
}
// 右键菜单
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
contextMenu.value = { x: e.clientX, y: e.clientY, path, type }
}
// 绑定事件
element.addEventListener('mouseenter', handleMouseEnter)
element.addEventListener('mouseleave', handleMouseLeave)
element.addEventListener('click', handleClick)
element.addEventListener('mousedown', handleMouseDown)
element.addEventListener('contextmenu', handleContextMenu)
console.log(`[注入] ${type} 路径: ${path}`)
// 返回清理函数
return () => {
element.removeEventListener('mouseenter', handleMouseEnter)
element.removeEventListener('mouseleave', handleMouseLeave)
element.removeEventListener('click', handleClick)
element.removeEventListener('mousedown', handleMouseDown)
element.removeEventListener('contextmenu', handleContextMenu)
element.classList.remove('fauto-interactive', 'fauto-hover', 'fauto-selected')
element.removeAttribute('data-fauto-bindend')
}
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenu.value = null
}
// 删除元素
const deleteElement = async () => {
if (!contextMenu.value || !vueFileStore.selectedFilePath) return
const { path } = contextMenu.value
if (!confirm('确定要删除该元素吗?')) {
closeContextMenu()
return
}
try {
const response = await fetch('http://localhost:3001/api/delete-element', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pagePath: vueFileStore.selectedFilePath,
elementPath: path
})
})
const result = await response.json()
if (result.success) {
console.log('[设计中心] 删除成功:', path)
window.dispatchEvent(new CustomEvent('vue-template-updated', {
detail: { pagePath: vueFileStore.selectedFilePath }
}))
// 如果删除的是选中的组件,清除选中
if (designStore.selectedComponent?.path === path) {
designStore.clearSelection()
}
} else {
alert('删除失败: ' + result.error)
}
} catch (error) {
console.error('[设计中心] 删除失败:', error)
}
closeContextMenu()
}
/**
* 扫描并注入事件到所有el-row和el-col元素
*/
const injectInteractionEvents = () => {
if (!containerRef.value) return
// 查找所有el-row和el-col元素
const rows = containerRef.value.querySelectorAll('.el-row')
const cols = containerRef.value.querySelectorAll('.el-col')
console.log(`[InteractiveWrapper] 发现 ${rows.length} 个 el-row, ${cols.length} 个 el-col`)
// 为el-row绑定事件
rows.forEach((row) => {
const path = generateElementPath(row)
const cleanup = bindElementEvents(row as HTMLElement, 'er', path)
if (cleanup) cleanupFunctions.push(cleanup)
})
// 为el-col绑定事件
cols.forEach((col) => {
const path = generateElementPath(col)
const cleanup = bindElementEvents(col as HTMLElement, 'ec', path)
if (cleanup) cleanupFunctions.push(cleanup)
})
}
/**
* 启动DOM观察器监听子元素变化
*/
const startObserver = () => {
if (!containerRef.value) return
observer = new MutationObserver((mutations) => {
// 检查是否有新的el-row或el-col添加
let hasNewElements = false
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node instanceof HTMLElement) {
if (node.classList.contains('el-row') || node.classList.contains('el-col')) {
hasNewElements = true
}
// 检查子元素
if (node.querySelector('.el-row, .el-col')) {
hasNewElements = true
}
}
})
})
if (hasNewElements) {
console.log('[MutationObserver] 检测到新元素,重新注入事件')
injectInteractionEvents()
}
})
observer.observe(containerRef.value, {
childList: true,
subtree: true
})
}
/**
* 键盘事件处理
*/
const handleKeyDown = (e: KeyboardEvent) => {
// 只在拖拽时响应键盘事件
if (!dragStore.isDragging) return
if (e.key === 'ArrowUp') {
e.preventDefault()
dragStore.selectParentLevel()
updateSelectedHighlight()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
dragStore.selectChildLevel()
updateSelectedHighlight()
} else if (e.key === 'Escape') {
e.preventDefault()
dragStore.cancelDrag()
clearDragHighlight()
}
}
/**
* 更新选中层级的高亮显示
*/
const updateSelectedHighlight = () => {
// 清除所有拖拽高亮
document.querySelectorAll('.fauto-drop-target').forEach(el => {
el.classList.remove('fauto-drop-target')
})
// 高亮当前选中的节点
if (dragStore.selectedNode?.element) {
dragStore.selectedNode.element.classList.add('fauto-drop-target')
}
}
/**
* 清除拖拽高亮
*/
const clearDragHighlight = () => {
document.querySelectorAll('.fauto-drop-target').forEach(el => {
el.classList.remove('fauto-drop-target')
})
}
/**
* 全局鼠标移动处理(拖拽时更新方向)
*/
const handleGlobalMouseMove = (e: MouseEvent) => {
// 只在目标选择阶段才更新方向
if (dragStore.isDragging && dragStore.dragPhase === 'target' && dragStore.selectedNode) {
dragStore.updateDirectionFromMouse(e.clientX, e.clientY)
}
}
/**
* 全局鼠标松开处理
*/
const handleGlobalMouseUp = async () => {
// 只在目标选择阶段才执行拖放
if (dragStore.isDragging && dragStore.dragPhase === 'target' && dragStore.selectedNode && dragStore.hoverDirection) {
// 获取当前页面路径从vueFileStore
const pagePath = vueFileStore.selectedFilePath
// 确认拖放,传入页面路径
await dragStore.confirmDrop(pagePath || undefined)
}
dragStore.endDrag()
clearDragHighlight()
}
// 组件挂载后注入事件
onMounted(() => {
// 等待异步组件加载完成使用多次nextTick和延时
nextTick(() => {
setTimeout(() => {
injectInteractionEvents()
startObserver()
}, 100)
})
// 添加键盘事件监听
document.addEventListener('keydown', handleKeyDown)
// 添加全局鼠标移动事件(用于更新拖放方向)
document.addEventListener('mousemove', handleGlobalMouseMove)
// 添加全局鼠标松开事件
document.addEventListener('mouseup', handleGlobalMouseUp)
// 点击其他地方关闭右键菜单
document.addEventListener('click', closeContextMenu)
})
// 组件卸载时清理事件
onUnmounted(() => {
cleanupFunctions.forEach(fn => fn())
cleanupFunctions.length = 0
if (observer) {
observer.disconnect()
observer = null
}
// 移除键盘事件监听
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousemove', handleGlobalMouseMove)
document.removeEventListener('mouseup', handleGlobalMouseUp)
document.removeEventListener('click', closeContextMenu)
})
// 监听组件变化,重新注入事件
watch(() => props.component, () => {
// 清理之前的绑定
cleanupFunctions.forEach(fn => fn())
cleanupFunctions.length = 0
nextTick(() => {
setTimeout(() => {
injectInteractionEvents()
}, 100)
})
})
// 监听悬停目标变化,拖拽时更新层级
watch(() => interactionStore.hoverTarget, (target) => {
if (dragStore.isDragging && target?.element) {
dragStore.updateHierarchy(target.element)
}
})
</script>
<template>
<div ref="containerRef" class="interactive-wrapper">
<component :is="component" />
<!-- 拖放区域指示器 -->
<DropZone />
<!-- 拖拽预览跟随鼠标 -->
<DragPreview />
<!-- 右键菜单 -->
<div
v-if="contextMenu"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div class="menu-item delete" @click="deleteElement">
<span class="menu-icon">🗑</span>
<span>删除</span>
</div>
</div>
<!-- 拖拽时的层级选择提示 -->
<div v-if="dragStore.isDragging && dragStore.hierarchyNodes.length > 1" class="hierarchy-hint">
<div class="hint-title"> 用上下键切换层级</div>
<div
v-for="(node, index) in dragStore.hierarchyNodes"
:key="node.path"
class="hint-item"
:class="{ 'is-selected': index === dragStore.selectedHierarchyIndex }"
>
<span class="hint-type">{{ node.type === 'er' ? 'Row' : 'Col' }}</span>
<span class="hint-path">{{ node.path }}</span>
</div>
</div>
</div>
</template>
<style>
/* 全局样式,用于交互元素 */
.interactive-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.fauto-interactive {
position: relative;
transition: outline 0.15s ease, box-shadow 0.15s ease;
}
.fauto-interactive.fauto-hover {
outline: 2px dashed #409eff;
outline-offset: -2px;
z-index: 10;
}
.fauto-interactive.fauto-selected {
outline: 2px solid #67c23a;
outline-offset: -2px;
z-index: 20;
box-shadow: 0 0 8px rgba(103, 194, 58, 0.4);
}
/* 拖放目标样式 */
.fauto-interactive.fauto-drop-target {
outline: 3px solid #e6a23c;
outline-offset: -3px;
z-index: 30;
background-color: rgba(230, 162, 60, 0.1) !important;
}
/* el-row 悬停时的特殊样式 */
.el-row.fauto-hover {
background-color: rgba(64, 158, 255, 0.05);
}
.el-row.fauto-selected {
background-color: rgba(103, 194, 58, 0.05);
}
/* el-col 悬停时的特殊样式 */
.el-col.fauto-hover {
background-color: rgba(64, 158, 255, 0.1);
}
.el-col.fauto-selected {
background-color: rgba(103, 194, 58, 0.1);
}
/* 层级选择提示 */
.hierarchy-hint {
position: fixed;
top: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
padding: 12px;
z-index: 1000;
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.hint-title {
color: #fff;
font-size: 12px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.hint-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 4px;
color: #999;
font-size: 12px;
transition: all 0.2s;
}
.hint-item.is-selected {
background: #409eff;
color: #fff;
}
.hint-type {
padding: 2px 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
font-size: 11px;
}
.hint-item.is-selected .hint-type {
background: rgba(255, 255, 255, 0.2);
}
.hint-path {
font-family: monospace;
}
/* 右键菜单 */
.context-menu {
position: fixed;
background: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 2000;
min-width: 120px;
padding: 4px 0;
}
.menu-item {
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: #ccc;
font-size: 13px;
transition: background 0.15s;
}
.menu-item:hover {
background: #094771;
}
.menu-item.delete:hover {
background: #c23a3a;
}
.menu-icon {
font-size: 14px;
}
</style>

View File

@@ -1,4 +1,4 @@
{
"name": "设计中心",
"description": "展示已添加的设计组件实例"
"description": "实时预览选中的Vue页面或设计组件"
}

View File

@@ -1,71 +1,56 @@
<script setup lang="ts">
import { defineAsyncComponent, markRaw } from 'vue'
import { useDesignStore } from '../../stores/designStore'
import { defineAsyncComponent, computed, watch } from 'vue'
import { useVueFileStore } from '../../stores/vueFileStore'
import InteractiveWrapper from './InteractiveWrapper.vue'
import config from './index.json'
const designStore = useDesignStore()
const vueFileStore = useVueFileStore()
// 自动扫描所有设计组件eager 模式确保同步加载)
const designComponentModules = import.meta.glob('../../designComponents/*/index.vue', { eager: true })
// 扫描所有views目录下的Vue文件
const viewModules = import.meta.glob('../../../views/**/*.vue')
// 自动构建设计组件映射表
const designComponentMap: Record<string, any> = {}
for (const path in designComponentModules) {
// 从路径中提取组件 ID例如 '../../designComponents/TextInput/index.vue' => 'TextInput'
const match = path.match(/\/designComponents\/(.+)\/index\.vue$/)
if (match) {
const id = match[1]
const mod = designComponentModules[path] as any
designComponentMap[id] = markRaw(mod.default || mod)
// 当前选中的Vue页面组件
const selectedPageComponent = computed(() => {
if (!vueFileStore.selectedFilePath) {
return null
}
}
const getComponent = (componentId: string) => {
return designComponentMap[componentId]
}
// 动态加载选中的组件
const loader = viewModules[vueFileStore.selectedFilePath]
if (loader) {
return defineAsyncComponent(loader as any)
}
const handleSelect = (instanceId: string) => {
designStore.selectComponent(instanceId)
}
return null
})
const handleRemove = (instanceId: string, event: Event) => {
event.stopPropagation()
designStore.removeComponent(instanceId)
}
// 监听选中文件变化
watch(() => vueFileStore.selectedFilePath, (newPath) => {
console.log('设计中心:选中的文件路径变化', newPath)
})
</script>
<template>
<div class="design-center">
<div class="center-header">
<span class="title">{{ config.name }}</span>
<span class="count">{{ designStore.components.length }} 个实例</span>
<span class="file-info" v-if="vueFileStore.selectedFilePath">
📄 {{ vueFileStore.selectedFileName }}
</span>
</div>
<div class="center-body">
<div
v-for="instance in designStore.components"
:key="instance.id"
class="component-row"
:class="{ selected: designStore.selectedId === instance.id }"
@click="handleSelect(instance.id)"
>
<div class="component-label">
<span class="component-name">{{ instance.name }}</span>
<button class="remove-btn" @click="handleRemove(instance.id, $event)">×</button>
</div>
<div class="component-preview">
<component
v-if="getComponent(instance.componentId)"
:is="getComponent(instance.componentId)"
v-bind="instance.props"
/>
</div>
<!-- 动态渲染选中的Vue页面使用InteractiveWrapper注入交互事件 -->
<div v-if="selectedPageComponent" class="page-preview">
<InteractiveWrapper :component="selectedPageComponent" />
</div>
<div v-if="designStore.components.length === 0" class="empty-tip">
<!-- 空状态 -->
<div v-else class="empty-tip">
<div class="empty-icon">🎨</div>
<div>暂无设计组件</div>
<div class="empty-hint">从左侧列表点击添加</div>
<div>暂无内容</div>
<div class="empty-hint">
点击页面管理选择Vue页面开始设计
</div>
</div>
</div>
</div>
@@ -94,9 +79,10 @@ const handleRemove = (instanceId: string, event: Event) => {
font-weight: 500;
}
.count {
color: #888888;
font-size: 11px;
.file-info {
color: #4fc3f7;
font-size: 12px;
font-weight: 500;
}
.center-body {
@@ -105,59 +91,12 @@ const handleRemove = (instanceId: string, event: Event) => {
overflow: auto;
}
.component-row {
margin-bottom: 12px;
border-radius: 6px;
border: 2px solid transparent;
transition: border-color 0.2s;
cursor: pointer;
}
.component-row:hover {
border-color: #3c3c3c;
}
.component-row.selected {
border-color: #007acc;
}
.component-label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #252526;
border-radius: 4px 4px 0 0;
}
.component-name {
color: #e0e0e0;
font-size: 12px;
}
.remove-btn {
width: 20px;
height: 20px;
background: transparent;
border: none;
color: #888888;
font-size: 16px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
background: #ff4444;
color: white;
}
.component-preview {
padding: 8px;
background: #1e1e1e;
border-radius: 0 0 4px 4px;
.page-preview {
width: 100%;
height: 100%;
background: white;
border-radius: 8px;
overflow: auto;
}
.empty-tip {

View File

@@ -1,19 +1,79 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useDesignStore } from '../../stores/designStore'
import { useInteractionStore, useDragStore } from '../../plugins'
import type { InteractionTarget } from '../../plugins'
import config from './index.json'
const designStore = useDesignStore()
const interactionStore = useInteractionStore()
const dragStore = useDragStore()
// 拖拽状态
const draggingId = ref<string | null>(null)
let dragStartTimer: number | null = null
onMounted(() => {
// 确保设计组件元数据已加载
if (designStore.componentMetas.length === 0) {
designStore.loadComponentMetas()
}
// 添加全局鼠标松开事件
document.addEventListener('mouseup', handleGlobalMouseUp)
})
const handleAddComponent = (componentId: string) => {
designStore.addComponent(componentId)
onUnmounted(() => {
document.removeEventListener('mouseup', handleGlobalMouseUp)
if (dragStartTimer) {
clearTimeout(dragStartTimer)
}
})
// 鼠标悬停设计组件
const handleMouseEnter = (componentId: string, componentName: string) => {
const target: InteractionTarget = {
type: 'dc',
path: componentId,
componentId: componentName
}
interactionStore.onHover(target)
}
// 鼠标离开设计组件
const handleMouseLeave = () => {
interactionStore.onLeave()
}
// 鼠标点击设计组件
const handleClick = (componentId: string, componentName: string) => {
// 如果正在拖拽,不触发点击
if (dragStore.isDragging) return
const target: InteractionTarget = {
type: 'dc',
path: componentId,
componentId: componentName
}
interactionStore.onClick(target)
}
// 鼠标按下 - 开始拖拽
const handleMouseDown = (e: MouseEvent, componentId: string, componentName: string) => {
e.preventDefault()
// 立即开始拖拽
draggingId.value = componentId
dragStore.startDragFromComponentList(componentId, componentName)
}
// 全局鼠标松开
const handleGlobalMouseUp = () => {
if (dragStartTimer) {
clearTimeout(dragStartTimer)
dragStartTimer = null
}
draggingId.value = null
}
</script>
@@ -28,14 +88,18 @@ const handleAddComponent = (componentId: string) => {
v-for="meta in designStore.componentMetas"
:key="meta.id"
class="component-item"
@click="handleAddComponent(meta.id)"
:class="{ 'is-dragging': draggingId === meta.id }"
@click="handleClick(meta.id, meta.name)"
@mouseenter="handleMouseEnter(meta.id, meta.name)"
@mouseleave="handleMouseLeave"
@mousedown="handleMouseDown($event, meta.id, meta.name)"
>
<div class="component-icon">📦</div>
<div class="component-icon">{{ meta.icon }}</div>
<div class="component-info">
<div class="component-name">{{ meta.name }}</div>
<div class="component-desc">{{ meta.description }}</div>
</div>
<div class="add-btn">+</div>
<div class="drag-handle"></div>
</div>
<div v-if="designStore.componentMetas.length === 0" class="empty-tip">
@@ -51,6 +115,7 @@ const handleAddComponent = (componentId: string) => {
flex-direction: column;
height: 100%;
background: #1e1e1e;
position: relative;
}
.list-header {
@@ -87,15 +152,21 @@ const handleAddComponent = (componentId: string) => {
background: #252526;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
cursor: grab;
transition: all 0.2s;
user-select: none;
}
.component-item.is-dragging {
opacity: 0.6;
cursor: grabbing;
}
.component-item:hover {
background: #2a2d2e;
}
.component-item:hover .add-btn {
.component-item:hover .drag-handle {
opacity: 1;
}
@@ -119,23 +190,20 @@ const handleAddComponent = (componentId: string) => {
font-size: 11px;
}
.add-btn {
.drag-handle {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: #007acc;
color: white;
border-radius: 4px;
font-size: 16px;
font-weight: bold;
color: #888888;
font-size: 14px;
opacity: 0;
transition: opacity 0.2s;
}
.add-btn:hover {
background: #0088e0;
.drag-handle:hover {
color: #cccccc;
}
.empty-tip {

View File

@@ -0,0 +1,4 @@
{
"name": "页面管理",
"description": "浏览和管理src/views目录下的Vue页面文件"
}

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useVueFileStore, type VueFileNode } from '../../stores/vueFileStore'
import config from './index.json'
const vueFileStore = useVueFileStore()
// 扩展的节点数据用于el-tree
interface TreeNodeData {
label: string
path: string
type: 'file' | 'folder'
children?: TreeNodeData[]
}
const treeData = ref<TreeNodeData[]>([])
const defaultExpandedKeys = ref<string[]>([])
// 扫描views目录下的所有Vue文件
const scanViewsDirectory = () => {
// 使用Vite的import.meta.glob扫描views目录
const viewModules = import.meta.glob('../../../views/**/*.vue')
console.log('扫描到的文件:', Object.keys(viewModules))
// 构建文件树
const fileTree = vueFileStore.buildFileTree(viewModules)
// 转换为el-tree需要的格式
const convertToTreeData = (nodes: VueFileNode[]): TreeNodeData[] => {
return nodes.map(node => ({
label: node.name,
path: node.path,
type: node.type,
children: node.children ? convertToTreeData(node.children) : undefined
}))
}
treeData.value = convertToTreeData(fileTree)
// 默认展开所有文件夹
defaultExpandedKeys.value = getAllFolderPaths(treeData.value)
}
// 获取所有文件夹路径(用于默认展开)
const getAllFolderPaths = (nodes: TreeNodeData[]): string[] => {
const paths: string[] = []
const traverse = (items: TreeNodeData[]) => {
items.forEach(item => {
if (item.type === 'folder') {
paths.push(item.path)
if (item.children) {
traverse(item.children)
}
}
})
}
traverse(nodes)
return paths
}
// 处理节点点击事件
const handleNodeClick = (data: TreeNodeData) => {
if (data.type === 'file') {
// 只有文件才能被选中
vueFileStore.selectFile(data.path, data.label)
}
}
// 自定义树节点图标
const getNodeIcon = (data: TreeNodeData) => {
if (data.type === 'folder') {
return '📁'
}
return '📄'
}
onMounted(() => {
// 初始化Vue文件选择store
vueFileStore.initialize()
// 扫描文件
scanViewsDirectory()
})
</script>
<template>
<div class="page-manager">
<div class="manager-header">
<span class="title">{{ config.name }}</span>
<span class="count">{{ treeData.length }} </span>
</div>
<div class="manager-body">
<div class="current-file" v-if="vueFileStore.selectedFileName">
<div class="label">当前文件:</div>
<div class="file-name">{{ vueFileStore.selectedFileName }}</div>
</div>
<div class="tree-container">
<el-tree
:data="treeData"
:props="{ label: 'label', children: 'children' }"
:default-expanded-keys="defaultExpandedKeys"
node-key="path"
highlight-current
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="custom-tree-node">
<span class="node-icon">{{ getNodeIcon(data) }}</span>
<span
class="node-label"
:class="{
'is-file': data.type === 'file',
'is-selected': data.path === vueFileStore.selectedFilePath
}"
>
{{ node.label }}
</span>
</div>
</template>
</el-tree>
<div v-if="treeData.length === 0" class="empty-tip">
<div class="empty-icon">📂</div>
<div>暂无Vue文件</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-manager {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
}
.manager-header {
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
color: #cccccc;
font-size: 13px;
font-weight: 500;
}
.count {
color: #888888;
font-size: 11px;
}
.manager-body {
flex: 1;
padding: 12px;
overflow: auto;
}
.current-file {
margin-bottom: 12px;
padding: 8px 12px;
background: #094771;
border-radius: 4px;
border-left: 3px solid #007acc;
}
.label {
color: #888888;
font-size: 11px;
margin-bottom: 4px;
}
.file-name {
color: #e0e0e0;
font-size: 12px;
font-weight: 500;
}
.tree-container {
font-size: 13px;
}
/* Element Plus Tree 样式覆盖 */
:deep(.el-tree) {
background: transparent;
color: #cccccc;
}
:deep(.el-tree-node__content) {
background: transparent;
height: 32px;
padding: 0 8px;
border-radius: 4px;
}
:deep(.el-tree-node__content:hover) {
background: #2a2d2e;
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background: #094771;
}
:deep(.el-tree-node__expand-icon) {
color: #888888;
}
.custom-tree-node {
display: flex;
align-items: center;
width: 100%;
}
.node-icon {
margin-right: 8px;
font-size: 14px;
}
.node-label {
flex: 1;
color: #cccccc;
}
.node-label.is-file {
cursor: pointer;
}
.node-label.is-file:hover {
color: #ffffff;
}
.node-label.is-selected {
color: #4fc3f7;
font-weight: 500;
}
.empty-tip {
color: #666666;
text-align: center;
padding: 40px 20px;
font-size: 13px;
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
</style>

View File

@@ -1,36 +1,4 @@
{
"name": "树形展示器",
"description": "用于展示树形结构数据的组件",
"props": {
"treeData": [
{
"id": "1",
"label": "项目根目录",
"expanded": true,
"children": [
{
"id": "1-1",
"label": "src",
"expanded": true,
"children": [
{ "id": "1-1-1", "label": "components" },
{ "id": "1-1-2", "label": "stores" },
{ "id": "1-1-3", "label": "types" },
{ "id": "1-1-4", "label": "App.vue" },
{ "id": "1-1-5", "label": "main.ts" }
]
},
{
"id": "1-2",
"label": "public",
"children": [
{ "id": "1-2-1", "label": "favicon.ico" }
]
},
{ "id": "1-3", "label": "package.json" },
{ "id": "1-4", "label": "vite.config.ts" }
]
}
]
}
"name": "结构",
"description": "展示当前页面的el-row/el-col布局结构"
}

View File

@@ -1,75 +1,392 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import draggable from 'vuedraggable'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useVueFileStore } from '../../stores/vueFileStore'
import { useDesignStore } from '../../stores/designStore'
import config from './index.json'
const TEMPLATE_SERVICE_URL = 'http://localhost:3001'
const vueFileStore = useVueFileStore()
const designStore = useDesignStore()
// 创建可响应的本地副本用于拖拽
const localComponents = ref([...designStore.components])
// 元素树节点类型
interface ElementNode {
type: 'row' | 'col'
path: string
label: string
componentName?: string | null
children?: ElementNode[]
}
// 监听 designStore 组件变化,同步到本地副本
watch(
() => designStore.components,
(newVal) => {
localComponents.value = [...newVal]
},
{ deep: true, immediate: true }
)
// 元素树数据
const elementTree = ref<ElementNode[]>([])
const loading = ref(false)
// 拖拽结束处理 - 同步顺序到 designStore
const onDragEnd = () => {
// 重新排序 designStore 中的组件
designStore.reorderComponents([...localComponents.value])
// 拖拽状态
const draggingNode = ref<ElementNode | null>(null)
const dropTarget = ref<{ node: ElementNode, position: 'before' | 'after' | 'inside' } | null>(null)
// 右键菜单状态
const contextMenu = ref<{ x: number, y: number, node: ElementNode } | null>(null)
// 选中的节点
const selectedPath = ref<string | null>(null)
// 获取页面元素结构
const fetchElementTree = async () => {
if (!vueFileStore.selectedFilePath) {
elementTree.value = []
return
}
loading.value = true
try {
const response = await fetch(
`${TEMPLATE_SERVICE_URL}/api/element-tree?pagePath=${encodeURIComponent(vueFileStore.selectedFilePath)}`
)
const result = await response.json()
if (result.success) {
elementTree.value = result.tree || []
} else {
elementTree.value = []
}
} catch (error) {
elementTree.value = []
} finally {
loading.value = false
}
}
// 获取节点显示文本
const getNodeText = (node: ElementNode): string => {
if (node.type === 'row') return node.path
if (node.componentName) return `${node.path}-${node.componentName}`
return node.path
}
// 判断是否为叶子节点
const isLeafNode = (node: ElementNode): boolean => {
return node.type === 'col' && !!node.componentName && (!node.children || node.children.length === 0)
}
// 计算允许的放置位置
const getAllowedPosition = (source: ElementNode, target: ElementNode): 'before' | 'after' | 'inside' | null => {
if (source.path === target.path) return null
if (target.path.startsWith(source.path)) return null
if (source.type === 'col' && target.type === 'col') return 'after'
if (source.type === 'row' && target.type === 'row') return 'after'
if (source.type === 'col' && target.type === 'row') return 'inside'
if (source.type === 'row' && target.type === 'col' && !isLeafNode(target)) return 'inside'
return null
}
// 点击节点 - 选中组件
const handleNodeClick = (nodeId: string) => {
designStore.selectComponent(nodeId)
const handleNodeClick = (node: ElementNode) => {
selectedPath.value = node.path
// 如果是叶子节点,选中组件显示元数据
if (isLeafNode(node) && node.componentName && vueFileStore.selectedFilePath) {
designStore.selectComponent(node.path, node.componentName, vueFileStore.selectedFilePath)
}
// 高亮设计中心对应元素
highlightDesignCenterElement(node.path)
}
// 获取组件类型图标
const getComponentIcon = (componentId: string) => {
const icons: Record<string, string> = {
TextInput: '✏️',
RadioSelect: '◉',
GridTable: '☰'
// 高亮设计中心的对应元素
const highlightDesignCenterElement = (path: string) => {
// 移除其他选中
document.querySelectorAll('.fauto-selected').forEach(el => {
el.classList.remove('fauto-selected')
})
// 找到对应元素并选中
const element = document.querySelector(`[data-path="${path}"]`)
if (element) {
element.classList.add('fauto-selected')
// 滚动到可见区域
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return icons[componentId] || '⭕'
}
// 监听设计中心的选中事件,同步选中结构树
const handleDesignComponentSelected = (e: CustomEvent) => {
const { path } = e.detail
if (path) {
selectedPath.value = path
}
}
// 右键菜单
const handleContextMenu = (e: MouseEvent, node: ElementNode) => {
e.preventDefault()
e.stopPropagation()
contextMenu.value = { x: e.clientX, y: e.clientY, node }
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenu.value = null
}
// 删除节点
const deleteNode = async (node: ElementNode) => {
if (!vueFileStore.selectedFilePath) return
if (!confirm(`确定要删除 ${getNodeText(node)} 吗?`)) return
try {
const response = await fetch(`${TEMPLATE_SERVICE_URL}/api/delete-element`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pagePath: vueFileStore.selectedFilePath,
elementPath: node.path
})
})
const result = await response.json()
if (result.success) {
console.log('[结构] 删除成功:', node.path)
window.dispatchEvent(new CustomEvent('vue-template-updated', {
detail: { pagePath: vueFileStore.selectedFilePath }
}))
// 如果删除的是选中的组件,清除选中
if (designStore.selectedComponent?.path === node.path) {
designStore.clearSelection()
}
} else {
alert('删除失败: ' + result.error)
}
} catch (error) {
console.error('[结构] 删除失败:', error)
}
closeContextMenu()
}
// 键盘事件处理
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' && selectedPath.value) {
const node = findNodeByPath(elementTree.value, selectedPath.value)
if (node) {
deleteNode(node)
}
}
if (e.key === 'Escape') {
closeContextMenu()
}
}
// 查找节点
const findNodeByPath = (nodes: ElementNode[], path: string): ElementNode | null => {
for (const node of nodes) {
if (node.path === path) return node
if (node.children) {
const found = findNodeByPath(node.children, path)
if (found) return found
}
}
return null
}
// 拖拽事件
const handleDragStart = (e: DragEvent, node: ElementNode) => {
draggingNode.value = node
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', node.path)
}
}
const handleDragOver = (e: DragEvent, node: ElementNode) => {
if (!draggingNode.value) return
const position = getAllowedPosition(draggingNode.value, node)
if (position) {
e.preventDefault()
dropTarget.value = { node, position }
} else {
dropTarget.value = null
}
}
const handleDragLeave = () => {
dropTarget.value = null
}
const handleDrop = async (e: DragEvent, node: ElementNode) => {
e.preventDefault()
if (!draggingNode.value || !vueFileStore.selectedFilePath) {
resetDragState()
return
}
const position = getAllowedPosition(draggingNode.value, node)
if (!position) {
resetDragState()
return
}
let direction: string
if (position === 'inside') {
direction = 'inside'
} else if (position === 'after') {
direction = node.type === 'row' ? 'bottom' : 'right'
} else {
direction = node.type === 'row' ? 'top' : 'left'
}
try {
const response = await fetch(`${TEMPLATE_SERVICE_URL}/api/move-element`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pagePath: vueFileStore.selectedFilePath,
source: {
type: 'canvas-element',
path: draggingNode.value.path,
elementType: draggingNode.value.type === 'row' ? 'er' : 'ec'
},
targetPath: node.path,
targetType: node.type === 'row' ? 'er' : 'ec',
direction
})
})
const result = await response.json()
if (result.success) {
window.dispatchEvent(new CustomEvent('vue-template-updated', {
detail: { pagePath: vueFileStore.selectedFilePath }
}))
}
} catch (error) {
console.error('[结构] 移动失败:', error)
}
resetDragState()
}
const handleDragEnd = () => {
resetDragState()
}
const resetDragState = () => {
draggingNode.value = null
dropTarget.value = null
}
// 递归渲染树
const flattenTree = (nodes: ElementNode[], depth = 0): Array<{ node: ElementNode, depth: number }> => {
const result: Array<{ node: ElementNode, depth: number }> = []
for (const node of nodes) {
result.push({ node, depth })
if (node.children && node.children.length > 0) {
result.push(...flattenTree(node.children, depth + 1))
}
}
return result
}
// 获取节点样式类
const getNodeClass = (node: ElementNode) => {
const classes: string[] = []
if (node.type === 'row') classes.push('is-row')
if (node.type === 'col') classes.push('is-col')
if (draggingNode.value?.path === node.path) classes.push('is-dragging')
if (selectedPath.value === node.path) classes.push('is-selected')
if (dropTarget.value?.node.path === node.path) {
classes.push('is-drop-target')
classes.push(`drop-${dropTarget.value.position}`)
}
return classes
}
// 监听
watch(() => vueFileStore.selectedFilePath, () => {
fetchElementTree()
}, { immediate: true })
onMounted(() => {
window.addEventListener('vue-template-updated', () => {
setTimeout(fetchElementTree, 300)
})
// 监听设计中心的选中事件
window.addEventListener('design-component-selected', handleDesignComponentSelected as EventListener)
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', closeContextMenu)
// 阻止组件内的浏览器默认右键菜单
document.addEventListener('contextmenu', handleGlobalContextMenu)
})
// 全局右键菜单处理,阻止浏览器默认菜单
const handleGlobalContextMenu = (e: MouseEvent) => {
// 检查是否在树节点上点击
const target = e.target as HTMLElement
if (target.closest('.tree-node')) {
e.preventDefault()
}
}
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', closeContextMenu)
document.removeEventListener('contextmenu', handleGlobalContextMenu)
window.removeEventListener('design-component-selected', handleDesignComponentSelected as EventListener)
})
</script>
<template>
<div class="tree-viewer">
<div class="viewer-header">
<span class="title">{{ config.name }}</span>
<span class="hint">设计组件列表</span>
</div>
<div class="viewer-body">
<div class="tree-container">
<draggable
:list="localComponents"
item-key="id"
:animation="150"
ghost-class="node-ghost"
@end="onDragEnd"
>
<template #item="{ element: node }">
<div
class="tree-node"
:class="{ selected: designStore.selectedId === node.id }"
@click="handleNodeClick(node.id)"
>
<span class="node-icon">{{ getComponentIcon(node.componentId) }}</span>
<span class="node-label">{{ node.name }}</span>
</div>
</template>
</draggable>
<div v-if="loading" class="loading-tip">加载中...</div>
<div v-if="localComponents.length === 0" class="empty-tip">
暂无设计组件
<div v-else-if="elementTree.length === 0" class="empty-tip">
{{ vueFileStore.selectedFilePath ? '暂无布局元素' : '请先选择页面' }}
</div>
<div v-else class="tree-container">
<div
v-for="item in flattenTree(elementTree)"
:key="item.node.path"
class="tree-node"
:class="getNodeClass(item.node)"
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
draggable="true"
@click="handleNodeClick(item.node)"
@contextmenu="handleContextMenu($event, item.node)"
@dragstart="handleDragStart($event, item.node)"
@dragover="handleDragOver($event, item.node)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, item.node)"
@dragend="handleDragEnd"
>
<span class="node-text">{{ getNodeText(item.node) }}</span>
<span v-if="dropTarget?.node.path === item.node.path" class="drop-hint">
{{ dropTarget.position === 'inside' ? '放入' : '后面' }}
</span>
<span class="delete-icon" @click.stop="deleteNode(item.node)" title="删除">🗑</span>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div class="menu-item" @click="deleteNode(contextMenu.node)">
<span class="menu-icon">🗑</span>
<span>删除</span>
</div>
</div>
</div>
@@ -81,15 +398,13 @@ const getComponentIcon = (componentId: string) => {
flex-direction: column;
height: 100%;
background: #1e1e1e;
position: relative;
}
.viewer-header {
padding: 8px 12px;
background: #2d2d2d;
border-bottom: 1px solid #3c3c3c;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
@@ -98,11 +413,6 @@ const getComponentIcon = (componentId: string) => {
font-weight: 500;
}
.hint {
color: #666666;
font-size: 11px;
}
.viewer-body {
flex: 1;
padding: 8px;
@@ -110,43 +420,87 @@ const getComponentIcon = (componentId: string) => {
}
.tree-container {
font-size: 13px;
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
}
.tree-node {
padding: 4px 8px;
cursor: grab;
border-radius: 3px;
color: #888;
user-select: none;
margin-bottom: 1px;
transition: all 0.15s;
display: flex;
align-items: center;
padding: 8px 12px;
cursor: grab;
border-radius: 4px;
color: #cccccc;
user-select: none;
margin-bottom: 2px;
transition: all 0.15s;
}
.tree-node:active {
cursor: grabbing;
gap: 8px;
}
.tree-node:hover {
background: #2a2d2e;
}
.tree-node.selected {
.tree-node:hover .delete-icon {
opacity: 1;
}
.tree-node:active {
cursor: grabbing;
}
.tree-node.is-row {
color: #4fc3f7;
}
.tree-node.is-col {
color: #c586c0;
}
.tree-node.is-dragging {
opacity: 0.5;
}
.tree-node.is-selected {
background: #094771;
}
.node-icon {
width: 20px;
margin-right: 8px;
text-align: center;
.tree-node.is-drop-target {
background: #094771;
}
.node-label {
.tree-node.drop-inside {
border: 1px dashed #4fc3f7;
}
.tree-node.drop-after {
border-bottom: 2px solid #4fc3f7;
}
.node-text {
flex: 1;
}
.drop-hint {
font-size: 10px;
color: #4fc3f7;
background: rgba(79, 195, 247, 0.2);
padding: 1px 4px;
border-radius: 2px;
}
.delete-icon {
opacity: 0;
cursor: pointer;
font-size: 12px;
transition: opacity 0.15s;
}
.delete-icon:hover {
transform: scale(1.1);
}
.loading-tip,
.empty-tip {
color: #666666;
text-align: center;
@@ -154,10 +508,32 @@ const getComponentIcon = (componentId: string) => {
font-size: 12px;
}
/* 拖拽样式 */
.node-ghost {
opacity: 0.4;
background: #094771;
/* 右键菜单 */
.context-menu {
position: fixed;
background: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
min-width: 120px;
}
.menu-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: #ccc;
font-size: 12px;
}
.menu-item:hover {
background: #094771;
}
.menu-icon {
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,555 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { InteractionTarget, ElementType } from './interactionStore'
import { parsePath } from './pathUtils'
// 后端服务地址
const TEMPLATE_SERVICE_URL = 'http://localhost:3001'
/**
* 拖放方向
* - top/bottom: el-row 的上下方
* - left/right: el-col 的左右方
* - inside: 放入内部(跨类型拖放时)
*/
export type DropDirection = 'top' | 'bottom' | 'left' | 'right' | 'inside'
/**
* 拖拽源信息
*/
export interface DragSource {
type: 'design-component' | 'canvas-element' // 来源类型
componentId?: string // 设计组件ID设计组件列表
componentName?: string // 设计组件名称
path?: string // 元素路径(画布内元素)
elementType?: ElementType // 元素类型
}
/**
* 层级节点信息
*/
export interface HierarchyNode {
path: string // 结构化路径
type: ElementType // 类型 er/ec
element: HTMLElement // DOM元素
depth: number // 深度
}
/**
* 拖放记录
*/
export interface DropRecord {
source: DragSource // 拖拽源
targetPath: string // 目标路径
targetType: ElementType // 目标类型
direction: DropDirection // 拖放方向
timestamp: number // 时间戳
}
/**
* 拖拽管理Store
*/
export const useDragStore = defineStore('drag', () => {
// ========== 拖拽状态 ==========
// 是否正在拖拽
const isDragging = ref(false)
// 拖拽源
const dragSource = ref<DragSource | null>(null)
// 拖拽阶段: 'source' = 源选择阶段, 'target' = 目标选择阶段
const dragPhase = ref<'source' | 'target'>('source')
// 已确定的源元素信息(当进入目标选择阶段时保存)
const confirmedSource = ref<{
path: string
type: ElementType
element: HTMLElement
} | null>(null)
// ========== 层级选择状态 ==========
// 当前悬停位置的所有层级节点(从深到浅排序)
const hierarchyNodes = ref<HierarchyNode[]>([])
// 当前选中的层级索引0表示最深层级
const selectedHierarchyIndex = ref(0)
// 当前选中的层级节点
const selectedNode = computed(() => {
if (hierarchyNodes.value.length === 0) return null
const index = Math.min(selectedHierarchyIndex.value, hierarchyNodes.value.length - 1)
return hierarchyNodes.value[index]
})
// ========== 拖放区域状态 ==========
// 当前悬停的拖放方向
const hoverDirection = ref<DropDirection | null>(null)
// ========== 拖放记录 ==========
// 最后一条拖放记录
const lastDropRecord = ref<DropRecord | null>(null)
// 所有拖放记录
const dropRecords = ref<DropRecord[]>([])
// ========== 方法 ==========
/**
* 开始拖拽(从设计组件列表)
* 设计组件拖动直接进入目标选择阶段
*/
const startDragFromComponentList = (componentId: string, componentName: string) => {
isDragging.value = true
dragPhase.value = 'target' // 设计组件直接进入目标选择阶段
confirmedSource.value = null
dragSource.value = {
type: 'design-component',
componentId,
componentName
}
console.log('[DragStore] 开始拖拽设计组件:', componentName)
}
/**
* 开始拖拽(从画布内元素)
* @param path 元素路径
* @param elementType 元素类型
* @param element 元素DOM引用用于立即构建层级列表
*/
const startDragFromCanvas = (path: string, elementType: ElementType, element?: HTMLElement) => {
isDragging.value = true
dragPhase.value = 'source' // 进入源选择阶段
confirmedSource.value = null // 清除之前的确定源
dragSource.value = {
type: 'canvas-element',
path,
elementType
}
// 立即构建层级列表,让用户可以在原地通过上下键切换源元素层级
if (element) {
updateHierarchy(element)
}
console.log('[DragStore] 开始拖拽画布元素 (源选择阶段):', path)
}
/**
* 进入目标选择阶段
* 当鼠标移动到不同元素时调用
*/
const enterTargetPhase = (targetElement: HTMLElement) => {
if (dragPhase.value === 'source' && selectedNode.value) {
// 保存当前选中的源元素
confirmedSource.value = {
path: selectedNode.value.path,
type: selectedNode.value.type,
element: selectedNode.value.element
}
// 更新 dragSource 中的路径和类型
if (dragSource.value) {
dragSource.value.path = selectedNode.value.path
dragSource.value.elementType = selectedNode.value.type
}
console.log('[DragStore] 确定源元素:', confirmedSource.value.path)
}
// 进入目标选择阶段
dragPhase.value = 'target'
// 更新层级列表为目标元素的层级
updateHierarchy(targetElement)
console.log('[DragStore] 进入目标选择阶段')
}
/**
* 更新层级节点列表
* @param element 当前悬停的元素
*/
const updateHierarchy = (element: HTMLElement) => {
const nodes: HierarchyNode[] = []
let current: HTMLElement | null = element
while (current) {
const isRow = current.classList.contains('el-row')
const isCol = current.classList.contains('el-col')
if (isRow || isCol) {
const path = current.getAttribute('data-path') || ''
const type: ElementType = isRow ? 'er' : 'ec'
const depth = parsePath(path).length
nodes.push({
path,
type,
element: current,
depth
})
}
current = current.parentElement
}
// 按深度从深到浅排序(最深的在前面)
nodes.sort((a, b) => b.depth - a.depth)
// 检查层级结构是否改变(比较路径列表)
const oldPaths = hierarchyNodes.value.map(n => n.path).join(',')
const newPaths = nodes.map(n => n.path).join(',')
const hierarchyChanged = oldPaths !== newPaths
// 记录旧的选择深度和路径
const oldSelectedNode = hierarchyNodes.value[selectedHierarchyIndex.value]
const oldSelectedDepth = oldSelectedNode?.depth
const oldSelectedPath = oldSelectedNode?.path
// 始终更新层级节点列表
hierarchyNodes.value = nodes
// 如果层级结构改变,尝试保持相同深度或路径的选择
if (hierarchyChanged) {
// 1. 优先尝试找到相同路径的节点
if (oldSelectedPath) {
const samePathIndex = nodes.findIndex(n => n.path === oldSelectedPath)
if (samePathIndex !== -1) {
selectedHierarchyIndex.value = samePathIndex
console.log('[DragStore] 保持相同路径选择:', oldSelectedPath)
return
}
}
// 2. 尝试找到相同深度的节点
if (oldSelectedDepth !== undefined) {
const sameDepthIndex = nodes.findIndex(n => n.depth === oldSelectedDepth)
if (sameDepthIndex !== -1) {
selectedHierarchyIndex.value = sameDepthIndex
console.log('[DragStore] 保持相同深度选择:', oldSelectedDepth)
return
}
}
// 3. 找到最接近的深度
if (oldSelectedDepth !== undefined && nodes.length > 0) {
let closestIndex = 0
let minDiff = Math.abs((nodes[0]?.depth ?? 0) - oldSelectedDepth)
for (let i = 1; i < nodes.length; i++) {
const nodeDepth = nodes[i]?.depth ?? 0
const diff = Math.abs(nodeDepth - oldSelectedDepth)
if (diff < minDiff) {
minDiff = diff
closestIndex = i
}
}
selectedHierarchyIndex.value = closestIndex
console.log('[DragStore] 选择最接近深度:', nodes[closestIndex]?.path)
} else {
// 4. 默认选中最深层级
selectedHierarchyIndex.value = 0
console.log('[DragStore] 默认选择最深层级')
}
} else {
// 层级结构未变,保持当前索引(确保不超出范围)
selectedHierarchyIndex.value = Math.min(selectedHierarchyIndex.value, nodes.length - 1)
console.log('[DragStore] 保持当前选择索引:', selectedHierarchyIndex.value)
}
console.log('[DragStore] 更新层级:', nodes.map(n => n.path))
}
/**
* 清空层级节点
*/
const clearHierarchy = () => {
hierarchyNodes.value = []
selectedHierarchyIndex.value = 0
hoverDirection.value = null
dragPhase.value = 'source'
confirmedSource.value = null
}
/**
* 选择上一层级(键盘↑)
*/
const selectParentLevel = () => {
if (hierarchyNodes.value.length === 0) return
if (selectedHierarchyIndex.value < hierarchyNodes.value.length - 1) {
selectedHierarchyIndex.value++
console.log('[DragStore] 选择上层:', selectedNode.value?.path)
}
}
/**
* 选择下一层级(键盘↓)
*/
const selectChildLevel = () => {
if (hierarchyNodes.value.length === 0) return
if (selectedHierarchyIndex.value > 0) {
selectedHierarchyIndex.value--
console.log('[DragStore] 选择下层:', selectedNode.value?.path)
}
}
/**
* 设置悬停方向
*/
const setHoverDirection = (direction: DropDirection | null) => {
hoverDirection.value = direction
}
/**
* 是否为跨类型拖放(源和目标类型不同)
*/
const isCrossTypeDrop = computed(() => {
// 只有在目标选择阶段才考虑跨类型
if (dragPhase.value !== 'target') return false
if (!confirmedSource.value || !selectedNode.value) return false
const sourceType = confirmedSource.value.type
const targetType = selectedNode.value.type
// er 拖到 ec 或 ec 拖到 er 都是跨类型
return sourceType !== targetType
})
/**
* 根据鼠标位置自动计算拖放方向
* @param mouseX 鼠标X坐标
* @param mouseY 鼠标Y坐标
*/
const updateDirectionFromMouse = (mouseX: number, mouseY: number) => {
if (!selectedNode.value?.element) return
// 跨类型拖放时,始终显示 "inside"
if (isCrossTypeDrop.value) {
hoverDirection.value = 'inside'
return
}
const rect = selectedNode.value.element.getBoundingClientRect()
const isRow = selectedNode.value.type === 'er'
if (isRow) {
// Row: 上下方向
const centerY = rect.top + rect.height / 2
hoverDirection.value = mouseY < centerY ? 'top' : 'bottom'
} else {
// Col: 左右方向
const centerX = rect.left + rect.width / 2
hoverDirection.value = mouseX < centerX ? 'left' : 'right'
}
}
/**
* 确认拖放
* @param pagePath 当前页面路径相对于views目录
*/
const confirmDrop = async (pagePath?: string): Promise<DropRecord | null> => {
if (!isDragging.value || !dragSource.value || !selectedNode.value || !hoverDirection.value) {
console.log('[DragStore] 拖放条件不满足')
return null
}
const record: DropRecord = {
source: { ...dragSource.value },
targetPath: selectedNode.value.path,
targetType: selectedNode.value.type,
direction: hoverDirection.value,
timestamp: Date.now()
}
dropRecords.value.push(record)
lastDropRecord.value = record
console.log('[DragStore] 确认拖放:', record)
// 如果提供了页面路径发送API请求到后端
if (pagePath) {
if (record.source.type === 'canvas-element') {
// 画布内元素移动
await sendMoveRequest(pagePath, record)
} else if (record.source.type === 'design-component') {
// 设计组件插入
await sendInsertRequest(pagePath, record)
}
}
return record
}
/**
* 发送移动请求到后端服务
*/
const sendMoveRequest = async (pagePath: string, record: DropRecord) => {
try {
console.log('[DragStore] 发送移动请求:', { pagePath, record })
const response = await fetch(`${TEMPLATE_SERVICE_URL}/api/move-element`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pagePath,
source: record.source,
targetPath: record.targetPath,
targetType: record.targetType,
direction: record.direction
})
})
const result = await response.json()
if (result.success) {
console.log('[DragStore] 移动成功:', result.message)
// 触发页面刷新事件
window.dispatchEvent(new CustomEvent('vue-template-updated', { detail: { pagePath } }))
} else {
console.error('[DragStore] 移动失败:', result.error)
}
return result
} catch (error) {
console.error('[DragStore] API请求失败:', error)
return { success: false, error: (error as Error).message }
}
}
/**
* 发送设计组件插入请求到后端服务
*/
const sendInsertRequest = async (pagePath: string, record: DropRecord) => {
try {
console.log('[DragStore] 发送插入请求:', { pagePath, record })
const response = await fetch(`${TEMPLATE_SERVICE_URL}/api/insert-component`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pagePath,
componentId: record.source.componentId,
targetPath: record.targetPath,
direction: record.direction
})
})
const result = await response.json()
if (result.success) {
console.log('[DragStore] 插入成功:', result.message)
// 触发页面刷新事件
window.dispatchEvent(new CustomEvent('vue-template-updated', { detail: { pagePath } }))
} else {
console.error('[DragStore] 插入失败:', result.error)
}
return result
} catch (error) {
console.error('[DragStore] API请求失败:', error)
return { success: false, error: (error as Error).message }
}
}
/**
* 取消拖拽
*/
const cancelDrag = () => {
isDragging.value = false
dragSource.value = null
clearHierarchy()
console.log('[DragStore] 取消拖拽')
}
/**
* 结束拖拽
*/
const endDrag = () => {
isDragging.value = false
dragSource.value = null
clearHierarchy()
}
/**
* 格式化拖放记录为显示文本
*/
const formatDropRecord = (record: DropRecord | null): string => {
if (!record) return ''
const directionNames: Record<DropDirection, string> = {
'top': '上方',
'bottom': '下方',
'left': '左侧',
'right': '右侧',
'inside': '内部'
}
let sourceName = ''
if (record.source.type === 'design-component') {
sourceName = record.source.componentName || record.source.componentId || '设计组件'
} else {
sourceName = record.source.path || '画布元素'
}
return `${sourceName}${record.targetPath} ${directionNames[record.direction]}`
}
/**
* 获取当前选中节点应该显示的拖放方向选项
*/
const getDropDirections = computed((): DropDirection[] => {
if (!selectedNode.value) return []
// 跨类型拖放:显示"放入内部"
if (isCrossTypeDrop.value) {
return ['inside']
}
// el-row 显示上下
if (selectedNode.value.type === 'er') {
return ['top', 'bottom']
}
// el-col 显示左右
return ['left', 'right']
})
return {
// 状态
isDragging,
dragSource,
dragPhase,
confirmedSource,
hierarchyNodes,
selectedHierarchyIndex,
selectedNode,
hoverDirection,
lastDropRecord,
dropRecords,
getDropDirections,
isCrossTypeDrop,
// 方法
startDragFromComponentList,
startDragFromCanvas,
enterTargetPhase,
updateHierarchy,
clearHierarchy,
selectParentLevel,
selectChildLevel,
setHoverDirection,
updateDirectionFromMouse,
confirmDrop,
cancelDrag,
endDrag,
formatDropRecord
}
})

View File

@@ -0,0 +1,36 @@
/**
* Fauto 插件系统入口
*
* 提供全局交互钩子和工具函数
*/
// 导出交互Store
export { useInteractionStore } from './interactionStore'
export type {
ElementType,
InteractionEvent,
InteractionTarget,
InteractionState,
HookCallback
} from './interactionStore'
// 导出拖拽Store
export { useDragStore } from './dragStore'
export type {
DropDirection,
DragSource,
HierarchyNode,
DropRecord
} from './dragStore'
// 导出路径工具
export {
parsePath,
buildPath,
calculateSiblingIndex,
generateElementPath,
getParentPath,
getPathDepth,
isAncestorPath
} from './pathUtils'
export type { PathNode } from './pathUtils'

View File

@@ -0,0 +1,257 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
/**
* 交互元素类型
* - er: el-row
* - ec: el-col
* - dc: design-component (设计组件)
*/
export type ElementType = 'er' | 'ec' | 'dc'
/**
* 交互事件类型
*/
export type InteractionEvent =
| 'hover' // 鼠标悬停
| 'click' // 鼠标点击
| 'longpress' // 鼠标长按
| 'drag' // 长按并移动
| 'release' // 鼠标松开
/**
* 交互目标信息
*/
export interface InteractionTarget {
type: ElementType // 元素类型
path: string // 结构化路径ID (如 r1c2r1c1)
componentId?: string // 设计组件ID (仅dc类型)
element?: HTMLElement // DOM元素引用
}
/**
* 交互状态信息
*/
export interface InteractionState {
event: InteractionEvent // 当前事件类型
target: InteractionTarget // 目标元素信息
timestamp: number // 时间戳
}
/**
* 钩子函数回调类型
*/
export type HookCallback = (state: InteractionState) => void
/**
* 全局交互钩子插件
* 管理所有的鼠标交互事件和状态
*/
export const useInteractionStore = defineStore('interaction', () => {
// 当前悬停的元素
const hoverTarget = ref<InteractionTarget | null>(null)
// 当前点击/选中的元素
const selectedTarget = ref<InteractionTarget | null>(null)
// 当前拖拽的元素
const dragTarget = ref<InteractionTarget | null>(null)
// 拖拽放置的目标元素
const dropTarget = ref<InteractionTarget | null>(null)
// 最后一次交互状态用于Footer显示
const lastInteraction = ref<InteractionState | null>(null)
// 是否正在长按
const isLongPressing = ref(false)
// 是否正在拖拽
const isDragging = ref(false)
// 长按计时器
let longPressTimer: number | null = null
// 钩子函数注册表
const hooks = shallowRef<Map<InteractionEvent, Set<HookCallback>>>(new Map())
/**
* 注册钩子函数
*/
const registerHook = (event: InteractionEvent, callback: HookCallback) => {
if (!hooks.value.has(event)) {
hooks.value.set(event, new Set())
}
hooks.value.get(event)!.add(callback)
// 返回取消注册函数
return () => {
hooks.value.get(event)?.delete(callback)
}
}
/**
* 触发钩子函数
*/
const triggerHooks = (event: InteractionEvent, target: InteractionTarget) => {
const state: InteractionState = {
event,
target,
timestamp: Date.now()
}
// 更新最后交互状态
lastInteraction.value = state
// 触发所有注册的回调
const callbacks = hooks.value.get(event)
if (callbacks) {
callbacks.forEach(cb => cb(state))
}
console.log(`[Interaction] ${event}:`, target)
}
/**
* 鼠标悬停事件
*/
const onHover = (target: InteractionTarget) => {
hoverTarget.value = target
triggerHooks('hover', target)
}
/**
* 鼠标离开事件
*/
const onLeave = () => {
hoverTarget.value = null
}
/**
* 鼠标点击事件
*/
const onClick = (target: InteractionTarget) => {
selectedTarget.value = target
triggerHooks('click', target)
}
/**
* 鼠标按下事件(开始检测长按)
*/
const onMouseDown = (target: InteractionTarget) => {
// 清除之前的计时器
if (longPressTimer) {
clearTimeout(longPressTimer)
}
// 设置长按检测500ms后触发
longPressTimer = window.setTimeout(() => {
isLongPressing.value = true
dragTarget.value = target
triggerHooks('longpress', target)
}, 500)
}
/**
* 鼠标移动事件(长按状态下触发拖拽)
*/
const onMouseMove = (target: InteractionTarget) => {
if (isLongPressing.value && dragTarget.value) {
if (!isDragging.value) {
isDragging.value = true
}
dropTarget.value = target
triggerHooks('drag', target)
}
}
/**
* 鼠标松开事件
*/
const onMouseUp = () => {
// 清除长按计时器
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
// 如果正在拖拽,触发释放事件
if (isDragging.value && dragTarget.value && dropTarget.value) {
triggerHooks('release', dropTarget.value)
}
// 重置状态
isLongPressing.value = false
isDragging.value = false
dragTarget.value = null
dropTarget.value = null
}
/**
* 获取元素类型的中文名称
*/
const getTypeName = (type: ElementType): string => {
const names: Record<ElementType, string> = {
'er': 'el-row',
'ec': 'el-col',
'dc': '设计组件'
}
return names[type]
}
/**
* 获取事件类型的中文名称
*/
const getEventName = (event: InteractionEvent): string => {
const names: Record<InteractionEvent, string> = {
'hover': '悬停',
'click': '点击',
'longpress': '长按',
'drag': '拖拽',
'release': '释放'
}
return names[event]
}
/**
* 格式化交互状态为显示文本
*/
const formatInteraction = (state: InteractionState | null): string => {
if (!state) return '无交互'
const eventName = getEventName(state.event)
const typeName = getTypeName(state.target.type)
const path = state.target.path
const componentId = state.target.componentId
if (state.target.type === 'dc') {
return `${eventName}${typeName} [${componentId}]`
}
return `${eventName}${typeName} [${path}]`
}
return {
// 状态
hoverTarget,
selectedTarget,
dragTarget,
dropTarget,
lastInteraction,
isLongPressing,
isDragging,
// 方法
registerHook,
onHover,
onLeave,
onClick,
onMouseDown,
onMouseMove,
onMouseUp,
// 辅助方法
getTypeName,
getEventName,
formatInteraction
}
})

View File

@@ -0,0 +1,125 @@
/**
* 结构化路径ID生成器
* 用于为el-row和el-col生成唯一的结构化路径
*
* 路径格式: r{n}c{m}r{x}c{y}...
* - r: el-row
* - c: el-col
* - 数字: 在同级元素中的索引从1开始
*/
import type { ElementType } from './interactionStore'
/**
* 路径节点信息
*/
export interface PathNode {
type: ElementType
index: number
}
/**
* 解析结构化路径字符串
* @param path 路径字符串,如 "r1c2r1c1"
* @returns 路径节点数组
*/
export const parsePath = (path: string): PathNode[] => {
const nodes: PathNode[] = []
const regex = /(r|c)(\d+)/g
let match
while ((match = regex.exec(path)) !== null) {
nodes.push({
type: match[1] === 'r' ? 'er' : 'ec',
index: parseInt(match[2])
})
}
return nodes
}
/**
* 生成结构化路径字符串
* @param nodes 路径节点数组
* @returns 路径字符串
*/
export const buildPath = (nodes: PathNode[]): string => {
return nodes.map(node => {
const prefix = node.type === 'er' ? 'r' : 'c'
return `${prefix}${node.index}`
}).join('')
}
/**
* 计算元素在其父级中的索引
* @param element 目标元素
* @param className 要匹配的class名el-row 或 el-col
* @returns 索引从1开始
*/
export const calculateSiblingIndex = (element: Element, className: string): number => {
const parent = element.parentElement
if (!parent) return 1
// 通过class名称筛选同级元素Element Plus组件渲染后是div带class
const siblings = Array.from(parent.children).filter(
child => child.classList.contains(className)
)
const index = siblings.indexOf(element)
return index >= 0 ? index + 1 : 1
}
/**
* 递归生成元素的完整结构化路径
* @param element 目标元素
* @returns 结构化路径字符串
*/
export const generateElementPath = (element: Element): string => {
const pathNodes: PathNode[] = []
let current: Element | null = element
while (current) {
// Element Plus的el-row/el-col渲染后是div标签通过class来识别
if (current.classList.contains('el-row')) {
const index = calculateSiblingIndex(current, 'el-row')
pathNodes.unshift({ type: 'er', index })
} else if (current.classList.contains('el-col')) {
const index = calculateSiblingIndex(current, 'el-col')
pathNodes.unshift({ type: 'ec', index })
}
current = current.parentElement
}
return buildPath(pathNodes)
}
/**
* 获取路径的父路径
* @param path 当前路径
* @returns 父路径,如果已是根则返回空字符串
*/
export const getParentPath = (path: string): string => {
const nodes = parsePath(path)
if (nodes.length <= 1) return ''
return buildPath(nodes.slice(0, -1))
}
/**
* 获取路径的深度
* @param path 路径字符串
* @returns 深度(节点数量)
*/
export const getPathDepth = (path: string): number => {
return parsePath(path).length
}
/**
* 检查路径A是否是路径B的祖先
* @param ancestorPath 祖先路径
* @param descendantPath 后代路径
* @returns 是否为祖先关系
*/
export const isAncestorPath = (ancestorPath: string, descendantPath: string): boolean => {
return descendantPath.startsWith(ancestorPath) && descendantPath.length > ancestorPath.length
}

View File

@@ -1,202 +1,151 @@
import { defineStore } from 'pinia'
import { ref, watch, computed } from 'vue'
import { ref, computed } from 'vue'
// 设计组件实例
export interface DesignComponentInstance {
id: string // 实例唯一ID
componentId: string // 设计组件类型ID
name: string // 显示名称
props: Record<string, any> // 属性值
const TEMPLATE_SERVICE_URL = 'http://localhost:3001'
// 元数据字段定义
export interface MetadataField {
label: string
type: 'text' | 'number' | 'select' | 'boolean' | 'color' | 'columns'
min?: number
max?: number
options?: string[]
target: string
attr: string
}
// 设计组件定义
export interface DesignComponentMeta {
id: string
name: string
icon: string
description: string
template: string
defaultSpan: number
metadata?: Record<string, MetadataField>
}
// 选中的组件信息
export interface SelectedComponent {
path: string
componentId: string
componentName: string
props: Record<string, any>
}
// 生成唯一ID
const generateId = () => Math.random().toString(36).substring(2, 9)
export const useDesignStore = defineStore('design', () => {
// 设计中心已添加的组件实例列表
const components = ref<DesignComponentInstance[]>([])
// 当前选中的组件实例ID
const selectedId = ref<string | null>(null)
// 设计组件元数据缓存
const componentMetas = ref<DesignComponentMeta[]>([])
// 当前选中的组件
const selectedComponent = ref<SelectedComponent | null>(null)
// 自动扫描所有设计组件的 .json 配置文件
const designComponentMetaModules = import.meta.glob('../designComponents/*/index.json', { eager: true })
// 是否已加载
const isLoaded = ref(false)
// 自动扫描所有设计组件的模板文件
const designComponentTemplateModules = import.meta.glob('../designComponents/*/template.html', { eager: true, query: '?raw', import: 'default' })
// 当前选中的组件实例
const selectedComponent = computed(() => {
if (!selectedId.value) return null
return components.value.find(c => c.id === selectedId.value) || null
})
// 当前选中组件的元数据(属性定义)
const selectedComponentMeta = computed(() => {
if (!selectedComponent.value) return null
return componentMetas.value.find(m => m.id === selectedComponent.value!.componentId) || null
})
// 加载设计组件元数据(从本地文件自动扫描)
// 加载设计组件元数据
const loadComponentMetas = async () => {
try {
const metas: DesignComponentMeta[] = []
for (const path in designComponentMetaModules) {
// 从路径中提取组件 ID例如 '../designComponents/TextInput/index.json' => 'TextInput'
const match = path.match(/\/designComponents\/(.+)\/index\.json$/)
if (!match) continue
const id = match[1]
const componentId = match[1]
const mod = designComponentMetaModules[path] as any
const config = mod.default || mod
const templatePath = `../designComponents/${componentId}/template.html`
const templateContent = designComponentTemplateModules[templatePath] as string || ''
metas.push({
id,
id: config.id || componentId,
name: config.name,
icon: config.icon || '📦',
description: config.description,
props: config.props || {}
template: templateContent,
defaultSpan: config.defaultSpan || 12,
metadata: config.metadata
})
}
componentMetas.value = metas
console.log('加载设计组件元数据:', metas.length, '个组件')
} catch (error) {
console.error('加载设计组件元数据失败:', error)
}
}
// 加载设计中心状态
const loadState = async () => {
try {
const response = await fetch('/api/design-state')
if (response.ok) {
const data = await response.json()
if (data.components) {
components.value = data.components
}
if (data.selectedId) {
selectedId.value = data.selectedId
}
}
} catch (error) {
console.log('使用默认设计状态')
}
isLoaded.value = true
}
// 保存设计中心状态
const saveState = async () => {
try {
await fetch('/api/design-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
components: components.value,
selectedId: selectedId.value,
lastUpdated: new Date().toISOString()
})
})
} catch (error) {
console.error('保存设计状态失败:', error)
}
}
// 监听变化自动保存
watch([components, selectedId], () => {
if (isLoaded.value) {
saveState()
}
}, { deep: true })
// 添加设计组件到设计中心
const addComponent = (componentId: string) => {
const meta = componentMetas.value.find(m => m.id === componentId)
if (!meta) {
console.warn('未找到设计组件:', componentId)
return
}
// 计算同类型组件的数量
const sameTypeCount = components.value.filter(c => c.componentId === componentId).length
const instance: DesignComponentInstance = {
id: generateId(),
componentId: componentId,
name: `${meta.name} ${sameTypeCount + 1}`,
props: JSON.parse(JSON.stringify(meta.props))
}
components.value.push(instance)
selectedId.value = instance.id
}
// 移除设计组件
const removeComponent = (instanceId: string) => {
const index = components.value.findIndex(c => c.id === instanceId)
if (index > -1) {
components.value.splice(index, 1)
if (selectedId.value === instanceId) {
selectedId.value = components.value.length > 0 ? components.value[0].id : null
}
}
}
// 选中设计组件
const selectComponent = (instanceId: string | null) => {
selectedId.value = instanceId
}
// 更新组件属性
const updateComponentProps = (instanceId: string, key: string, value: any) => {
const component = components.value.find(c => c.id === instanceId)
if (component) {
component.props[key] = value
}
}
// 重新排序组件(拖拽后)
const reorderComponents = (newOrder: DesignComponentInstance[]) => {
components.value = newOrder
}
// 获取组件元数据
const getComponentMeta = (componentId: string) => {
return componentMetas.value.find(m => m.id === componentId)
}
// 根据名称获取组件元数据
const getComponentMetaByName = (name: string) => {
return componentMetas.value.find(m => m.name === name)
}
// 获取组件模板
const getComponentTemplate = (componentId: string): string | null => {
const meta = getComponentMeta(componentId)
return meta?.template || null
}
// 选中组件
const selectComponent = async (path: string, componentName: string, pagePath: string) => {
try {
// 获取组件属性
const response = await fetch(
`${TEMPLATE_SERVICE_URL}/api/component-props?pagePath=${encodeURIComponent(pagePath)}&elementPath=${encodeURIComponent(path)}`
)
const result = await response.json()
if (result.success) {
selectedComponent.value = {
path,
componentId: result.componentId || componentName,
componentName,
props: result.props || {}
}
console.log('[设计Store] 选中组件:', selectedComponent.value)
}
} catch (error) {
console.error('[设计Store] 获取组件属性失败:', error)
}
}
// 清除选中
const clearSelection = () => {
selectedComponent.value = null
}
// 获取当前选中组件的元数据schema
const selectedMetadataSchema = computed(() => {
if (!selectedComponent.value) return null
const meta = getComponentMetaByName(selectedComponent.value.componentName)
return meta?.metadata || null
})
// 初始化
const init = async () => {
await loadComponentMetas()
await loadState()
}
return {
components,
selectedId,
selectedComponent,
selectedComponentMeta,
componentMetas,
isLoaded,
selectedComponent,
selectedMetadataSchema,
init,
loadComponentMetas,
loadState,
saveState,
addComponent,
removeComponent,
getComponentMeta,
getComponentMetaByName,
getComponentTemplate,
selectComponent,
updateComponentProps,
reorderComponents,
getComponentMeta
clearSelection
}
})

View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "../../../.."
}
],
"settings": {}
}

View File

@@ -0,0 +1,158 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// Vue文件树节点
export interface VueFileNode {
name: string // 文件/文件夹名称
path: string // 完整路径
type: 'file' | 'folder' // 类型
children?: VueFileNode[] // 子节点
}
export const useVueFileStore = defineStore('vueFile', () => {
// 从 API 加载上次选中的文件
const loadFromApi = async () => {
try {
const response = await fetch('/api/vue-file-selection')
if (response.ok) {
const data = await response.json()
return data
}
} catch (error) {
console.error('加载Vue文件选择失败:', error)
}
return { path: null, name: null }
}
// 当前选中的Vue文件路径
const selectedFilePath = ref<string | null>(null)
// 当前选中的文件名
const selectedFileName = ref<string | null>(null)
// 文件树结构
const fileTree = ref<VueFileNode[]>([])
// 是否已加载
const isLoaded = ref(false)
// 初始化加载
const initialize = async () => {
const stored = await loadFromApi()
selectedFilePath.value = stored.path
selectedFileName.value = stored.name
isLoaded.value = true
console.log('初始化Vue文件选择:', stored)
}
// 设置选中的文件
const selectFile = (path: string, name: string) => {
selectedFilePath.value = path
selectedFileName.value = name
// 保存到 API
saveToApi({ path, name })
console.log('选中文件:', { path, name })
}
// 清除选中
const clearSelection = () => {
selectedFilePath.value = null
selectedFileName.value = null
// 清除 API 数据
saveToApi({ path: null, name: null })
}
// 保存到 API
const saveToApi = async (data: { path: string | null, name: string | null }) => {
try {
await fetch('/api/vue-file-selection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
} catch (error) {
console.error('保存Vue文件选择失败:', error)
}
}
// 构建文件树从import.meta.glob结果
const buildFileTree = (modules: Record<string, any>): VueFileNode[] => {
const tree: VueFileNode[] = []
const folderMap = new Map<string, VueFileNode>()
for (const path in modules) {
// 移除开头的 '../views/' 或 './views/'
const relativePath = path.replace(/^\.\.?\/views\//, '')
const parts = relativePath.split('/')
let currentLevel = tree
let currentPath = ''
// 处理路径的每一部分
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
currentPath = currentPath ? `${currentPath}/${part}` : part
const isFile = i === parts.length - 1 && part.endsWith('.vue')
if (isFile) {
// 添加文件节点
currentLevel.push({
name: part,
path: path,
type: 'file'
})
} else {
// 添加文件夹节点
let folder = folderMap.get(currentPath)
if (!folder) {
folder = {
name: part,
path: currentPath,
type: 'folder',
children: []
}
currentLevel.push(folder)
folderMap.set(currentPath, folder)
}
currentLevel = folder.children!
}
}
}
// 排序:文件夹在前,文件在后
const sortTree = (nodes: VueFileNode[]) => {
nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1
}
return a.name.localeCompare(b.name)
})
nodes.forEach(node => {
if (node.children) {
sortTree(node.children)
}
})
}
sortTree(tree)
return tree
}
return {
selectedFilePath,
selectedFileName,
fileTree,
isLoaded,
initialize,
selectFile,
clearSelection,
buildFileTree
}
})

View File

@@ -1,5 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
import router from './router'
@@ -9,4 +11,5 @@ const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -29,3 +29,18 @@ html, body {
width: 100%;
height: 100%;
}
/* 拖拽时禁用文本选择 */
body.is-dragging {
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
cursor: grabbing !important;
}
body.is-dragging * {
user-select: none !important;
-webkit-user-select: none !important;
cursor: grabbing !important;
}

View File

@@ -0,0 +1,92 @@
<template>
<el-row class="page-container" :gutter="20">
<el-col :span="12">
<div class="design-component design-text-input">
<el-form-item label="文本输入">
<el-input v-model="inputValue" placeholder="请输入内容"></el-input>
</el-form-item>
</div>
</el-col>
<el-col :span="12">
<el-row :gutter="10">
<el-col :span="12">
<div class="design-component">右侧列2</div>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<div class="design-component design-radio-select">
<el-form-item label="单选选择">
<el-radio-group v-model="radioValue">
<el-radio label="选项1" value="1" />
<el-radio label="选项2" value="2" />
<el-radio label="选项3" value="3" />
</el-radio-group>
</el-form-item>
</div>
</el-col>
<el-col :span="12">
<div class="design-component">左侧内容区域</div>
</el-col>
</el-row>
</el-col>
<el-col :span="12">
<div class="design-component design-radio-select" data-component="单选器">
<el-form-item label="单选选择">
<el-radio-group text-color="#da0707" fill="#703e67" >
<el-radio label="选项1" value="1" />
<el-radio label="选项2" value="2" />
<el-radio label="选项3" value="3" />
</el-radio-group>
</el-form-item>
</div>
</el-col>
<el-col :span="16">
<div class="design-component design-grid-table" data-component="表格">
<el-table border="false" stripe="false" size="mini" :data="tableData" style="width: 100%">
<el-table-column prop="col1" label="列1" />
<el-table-column prop="col2" label="列2" />
<el-table-column prop="col3" label="列3" />
</el-table>
</div>
</el-col>
<el-col :span="12">
<div class="design-component design-text-input" data-component="输入框">
<el-form-item label="文本输入">
<el-input v-model="inputValue" placeholder="请输入内容111"></el-input>
</el-form-item>
</div>
</el-col>
</el-row>
</template>
<script setup lang="ts">
// 测试页面1 - 基础布局
</script>
<style scoped>
.page-container {
padding: 20px;
background: #f5f5f5;
min-height: 100%;
}
.design-component {
background: #409eff;
color: white;
padding: 30px;
text-align: center;
border-radius: 4px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<el-row class="page-container" :gutter="20">
<el-col :span="24">
<div class="design-component form-title">表单标题区域</div>
</el-col>
<el-col :span="6">
<div class="design-component">用户名</div>
</el-col>
<el-col :span="18">
<div class="design-component">输入框占位</div>
</el-col>
<el-col :span="6">
<div class="design-component">密码</div>
</el-col>
<el-col :span="18">
<div class="design-component">密码框占位</div>
</el-col>
<el-col :span="24">
<div class="design-component submit-btn">提交按钮</div>
</el-col>
</el-row>
</template>
<script setup lang="ts">
// 测试页面2 - 表单布局
</script>
<style scoped>
.page-container {
padding: 20px;
background: #fafafa;
min-height: 100%;
}
.design-component {
background: #67c23a;
color: white;
padding: 20px;
text-align: center;
border-radius: 4px;
margin-bottom: 10px;
}
.form-title {
background: #409eff;
font-size: 18px;
font-weight: bold;
}
.submit-btn {
background: #e6a23c;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<el-row class="page-container" :gutter="20">
<el-col :span="6">
<div class="design-component stat-card">
<div class="stat-title">总用户</div>
<div class="stat-value">1,234</div>
</div>
</el-col>
<el-col :span="6">
<div class="design-component stat-card">
<div class="stat-title">活跃用户</div>
<div class="stat-value">856</div>
</div>
</el-col>
<el-col :span="6">
<div class="design-component stat-card">
<div class="stat-title">订单数</div>
<div class="stat-value">432</div>
</div>
</el-col>
<el-col :span="6">
<div class="design-component stat-card">
<div class="stat-title">收入</div>
<div class="stat-value">¥12,345</div>
</div>
</el-col>
<el-col :span="16">
<div class="design-component chart-area">图表区域占位</div>
</el-col>
<el-col :span="8">
<div class="design-component sidebar">侧边栏信息</div>
</el-col>
</el-row>
</template>
<script setup lang="ts">
// 仪表板概览页面
</script>
<style scoped>
.page-container {
padding: 20px;
background: #f0f2f5;
min-height: 100%;
}
.design-component {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.stat-card {
text-align: center;
}
.stat-title {
color: #666;
font-size: 14px;
margin-bottom: 10px;
}
.stat-value {
color: #333;
font-size: 24px;
font-weight: bold;
}
.chart-area {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
background: #e8f4ff;
}
.sidebar {
height: 300px;
background: #f0f9ff;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<el-row class="page-container" :gutter="20">
<el-col :span="8">
<div class="design-component avatar-section">
<div class="avatar">头像</div>
</div>
</el-col>
<el-col :span="16">
<el-row :gutter="10">
<el-col :span="12">
<div class="design-component info-item">姓名: 张三</div>
</el-col>
<el-col :span="12">
<div class="design-component info-item">邮箱: zhang@example.com</div>
</el-col>
<el-col :span="12">
<div class="design-component info-item">电话: 138****8888</div>
</el-col>
<el-col :span="12">
<div class="design-component info-item">部门: 技术部</div>
</el-col>
</el-row>
</el-col>
</el-row>
</template>
<script setup lang="ts">
// 用户资料页面
</script>
<style scoped>
.page-container {
padding: 20px;
background: #f5f5f5;
min-height: 100%;
}
.design-component {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 10px;
}
.avatar-section {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.avatar {
width: 120px;
height: 120px;
background: #409eff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.info-item {
padding: 15px;
background: #f9f9f9;
color: #666;
}
</style>

View File

@@ -8,6 +8,7 @@ import type { Plugin } from 'vite'
const CONFIG_FILE = path.resolve(__dirname, 'config.json')
const DESIGN_STATE_FILE = path.resolve(__dirname, 'design-state.json')
const MATERIAL_STATES_FILE = path.resolve(__dirname, 'material-states.json')
const VUE_FILE_SELECTION_FILE = path.resolve(__dirname, 'vue-file-selection.json')
const DESIGN_COMPONENTS_DIR = path.resolve(__dirname, 'src/designComponents')
// 通用JSON文件读写处理器
@@ -52,6 +53,7 @@ function configApiPlugin(): Plugin {
const configHandler = createJsonHandler(CONFIG_FILE)
const designStateHandler = createJsonHandler(DESIGN_STATE_FILE)
const materialStatesHandler = createJsonHandler(MATERIAL_STATES_FILE)
const vueFileSelectionHandler = createJsonHandler(VUE_FILE_SELECTION_FILE)
return {
name: 'config-api',
@@ -158,6 +160,35 @@ function configApiPlugin(): Plugin {
next()
}
})
// Vue文件选择状态 API
server.middlewares.use('/api/vue-file-selection', (req, res, next) => {
if (req.method === 'GET') {
try {
res.setHeader('Content-Type', 'application/json')
res.end(vueFileSelectionHandler.read())
} catch (error) {
res.statusCode = 500
res.end(JSON.stringify({ error: 'Failed to read vue file selection' }))
}
} else if (req.method === 'POST') {
let body = ''
req.on('data', (chunk) => body += chunk.toString())
req.on('end', () => {
try {
const selection = JSON.parse(body)
vueFileSelectionHandler.write(JSON.stringify(selection, null, 2))
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
} catch (error) {
res.statusCode = 500
res.end(JSON.stringify({ error: 'Failed to save vue file selection' }))
}
})
} else {
next()
}
})
}
}
}

View File

@@ -0,0 +1,4 @@
{
"path": "../../../views/TestPage1.vue",
"name": "TestPage1.vue"
}

View File

@@ -1,199 +0,0 @@
# 可拖拽子窗口项目设计文档
## 项目概述
本项目是一个基于 Vite + Vue3 + TypeScript 的可拖拽子窗口系统,模仿 IDE如 IntelliJ IDEA、Visual Studio的界面设计提供多区域可拖拽的窗口管理功能。
## 技术架构
### 核心技术栈
- **构建工具**: Vite 7.3.0
- **前端框架**: Vue 3.5.24 (Composition API)
- **状态管理**: Pinia 3.0.4
- **类型系统**: TypeScript
- **拖拽库**: vuedraggable 4.1.0
- **样式**: CSS Modules + Scoped Styles
### 项目结构
```
src/
├── assets/ # 静态资源
├── components/ # 核心UI组件
│ ├── Header.vue # 顶部菜单栏
│ ├── Footer.vue # 底部状态栏
│ ├── MainLayout.vue # 主布局容器
│ ├── Panel.vue # 面板容器
│ └── Resizer.vue # 面板分隔器
├── materials/ # 物料组件系统
│ ├── index.ts # 物料组件注册中心
│ ├── TextEditor/ # 文本编辑器
│ ├── TreeViewer/ # 树形展示器
│ ├── DataTable/ # 数据表格
│ ├── TestWidget*/ # 测试组件
│ ├── DesignComponentList/ # 设计组件列表
│ └── DesignCenter/ # 设计中心
├── designComponents/ # 设计组件库
│ ├── TextInput/ # 文本输入框
│ ├── RadioSelect/ # 单选器
│ └── GridTable/ # 表格组件
├── stores/ # 状态管理
│ ├── panelStore.ts # 面板布局状态
│ └── designStore.ts # 设计中心状态
├── types/ # TypeScript 类型定义
└── App.vue # 应用入口
```
## 核心功能模块
### 1. 面板系统 (Panel System)
#### 布局结构
- **Header**: 固定顶部,包含菜单和新增窗口按钮
- **MainLayout**: 三区域布局(左/中/右)
- **Panel**: 可拖拽的面板容器
- **Footer**: 固定底部,显示时间和状态信息
#### 拖拽实现
使用 `vuedraggable` 库实现跨面板的 Tab 拖拽:
```vue
<draggable
:list="panel.tabs"
group="tabs"
item-key="id"
:animation="150"
>
```
### 2. 物料组件系统 (Material System)
#### 规范标准
遵循统一的物料组件规范:
1. 每个组件独立文件夹
2. 包含 `index.vue`(组件实现)和 `index.json`(元数据)
3. `index.json` 定义:名称、描述、属性及默认值
#### 组件状态管理
- 使用 `materialState` 属性传递组件状态
- 通过 `update:state` 事件实现状态更新
- 物料组件状态独立存储,组件关闭后状态仍保留
#### 状态持久化
通过自定义 Vite 插件实现 API 中间件:
- `/api/config`: 布局配置读写
- `/api/material-states`: 物料组件状态读写
- `/api/design-components`: 设计组件元数据读取
- `/api/design-state`: 设计中心状态读写
### 3. 设计组件系统 (Design System)
#### 组件构成
1. **设计组件库** (`designComponents/`)
- TextInput: 文本输入框属性label, width, maxLength
- RadioSelect: 单选器属性options
- GridTable: 表格属性rows, columns, headers
2. **物料组件**
- DesignComponentList: 展示可用设计组件
- DesignCenter: 展示已添加的设计组件实例
- TreeViewer: 展示设计中心组件列表(支持拖拽排序)
- DataTable: 展示选中组件的属性(支持编辑)
#### 跨组件联动机制
1. 点击 DesignComponentList 中的组件添加到 DesignCenter
2. 点击 DesignCenter 中的组件,在 DataTable 中展示其属性
3. 在 TreeViewer 中拖拽组件调整 DesignCenter 的顺序
4. 在 DataTable 中双击属性值进行编辑
## 状态管理设计
### PanelStore (面板状态)
管理整个应用的布局和物料组件状态:
- `layout`: 三区域面板配置
- `materialStates`: 物料组件状态独立存储
- 提供 Tab 操作 API添加、关闭、移动、激活
### DesignStore (设计中心状态)
管理设计组件系统的状态:
- `components`: 已添加的设计组件实例列表
- `selectedId`: 当前选中的组件实例ID
- `componentMetas`: 设计组件元数据缓存
## 实现细节
### 1. 拖拽功能优化
- 使用 `:list` 属性而非 `v-model` 解决拖拽位置问题
- 实现跨面板 Tab 拖拽
- 面板宽度可调整(通过 Resizer 组件)
### 2. 状态持久化策略
- 布局配置和物料状态分别存储
- 使用防抖机制避免频繁保存TextEditor 500ms
- 组件移除后状态仍保留
### 3. 组件通信机制
- 父子组件Props / Events
- 兄弟组件:通过 Pinia Store
- 物料组件:`materialState` / `update:state`
### 4. 性能优化
- 使用 `defineAsyncComponent` 异步加载物料组件
- 使用 `markRaw` 避免不必要的响应式转换
- 使用 `computed` 缓存派生数据
## API 设计
### 配置管理 API
```
GET /api/config # 获取布局配置
POST /api/config # 保存布局配置
GET /api/material-states # 获取物料组件状态
POST /api/material-states # 保存物料组件状态
```
### 设计系统 API
```
GET /api/design-components # 获取设计组件元数据
GET /api/design-state # 获取设计中心状态
POST /api/design-state # 保存设计中心状态
```
## 开发规范
### 代码规范
1. 使用 TypeScript 严格模式
2. 组件 Props 必须明确定义类型
3. 使用 Composition API 组织代码逻辑
4. 样式使用 scoped 避免全局污染
### 组件开发规范
1. 遵循物料组件标准化规范
2. 状态通过 `materialState` 接收,通过 `update:state` 更新
3. 实现防抖保存机制避免频繁 IO
4. 提供清晰的组件文档index.json
### 状态管理规范
1. 使用 Pinia 进行全局状态管理
2. 状态变更必须通过 Store 的方法
3. 复杂状态逻辑封装在 Store 内部
4. 状态持久化与 UI 逻辑分离
## 扩展性设计
### 新增物料组件
1.`materials/` 目录下创建组件文件夹
2. 实现 `index.vue``index.json`
3.`materials/index.ts` 中注册组件
### 新增设计组件
1.`designComponents/` 目录下创建组件文件夹
2. 实现 `index.vue``index.json`
3. 在 DesignComponentList 中会自动显示
### 功能扩展
1. 面板系统支持更多布局模式
2. 物料组件支持更多交互类型
3. 设计系统支持更复杂的属性编辑器
## 总结
本项目实现了完整的可拖拽子窗口系统,具备良好的架构设计和扩展性。通过物料组件系统和设计组件系统的分离,既满足了基础的窗口管理需求,又提供了高级的设计能力。状态持久化机制确保了用户体验的连续性,而规范化的开发流程保证了项目的可维护性。

20
vue-template-service/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Logs
logs
*.log
npm-debug.log*
# Dependencies
node_modules
# Build output
dist
# Editor directories and files
.vscode
.idea
.DS_Store
*.suo
*.sw?
# OS files
Thumbs.db

1067
vue-template-service/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
{
"name": "vue-template-service",
"version": "1.0.0",
"description": "Vue模板源码修改服务",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"@vue/compiler-sfc": "^3.4.0"
},
"devDependencies": {
"@types/node": "^20.10.0"
}
}

View File

@@ -0,0 +1,408 @@
/**
* Vue模板源码修改服务
*
* 接收前端拖放请求修改Vue文件中的template结构
*/
import express from 'express'
import cors from 'cors'
import { moveElement, insertElement, parseElementTree, parseComponentProps, updateComponentProps, deleteElement } from './services/templateService.js'
import { readFile, writeFile } from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
const PORT = 3001
// 中间件
app.use(cors())
app.use(express.json())
// Vue源码目录相对于服务根目录
const VUE_SOURCE_DIR = path.resolve('../draggable-panels/src/views')
// 设计组件模板目录
const DESIGN_COMPONENTS_DIR = path.resolve('../draggable-panels/src/fauto/designComponents')
/**
* API: 执行元素移动操作
*
* POST /api/move-element
* Body: {
* pagePath: string, // Vue文件路径相对于views目录
* source: { type, path, elementType },
* targetPath: string,
* targetType: string,
* direction: 'top' | 'bottom' | 'left' | 'right'
* }
*/
app.post('/api/move-element', async (req, res) => {
try {
const { pagePath, source, targetPath, targetType, direction } = req.body
console.log('[API] 收到移动请求:', {
pagePath,
source: source.path,
target: targetPath,
direction
})
// 验证参数
if (!pagePath || !source?.path || !targetPath || !direction) {
return res.status(400).json({
success: false,
error: '缺少必要参数'
})
}
// 构建完整文件路径
// 前端传来的路径可能是多种格式:
// - "../../../views/TestPage1.vue"
// - "../views/xxx.vue"
// - "./views/xxx.vue"
// - "TestPage1.vue"
// 需要提取 views/ 后面的部分
let normalizedPath = pagePath
// 查找 views/ 的位置,取其后面的内容
const viewsIndex = normalizedPath.indexOf('views/')
if (viewsIndex !== -1) {
normalizedPath = normalizedPath.substring(viewsIndex + 6) // 6 = 'views/'.length
}
const filePath = path.join(VUE_SOURCE_DIR, normalizedPath)
console.log('[API] 原始路径:', pagePath)
console.log('[API] 规范化路径:', normalizedPath)
console.log('[API] 完整路径:', filePath)
// 读取Vue文件
let vueContent
try {
vueContent = await readFile(filePath, 'utf-8')
} catch (err) {
return res.status(404).json({
success: false,
error: `文件不存在: ${pagePath}`
})
}
// 执行移动操作
const result = moveElement(vueContent, {
sourcePath: source.path,
targetPath,
direction
})
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error
})
}
// 写回文件
await writeFile(filePath, result.content, 'utf-8')
console.log('[API] 移动成功')
res.json({
success: true,
message: `已将 ${source.path} 移动到 ${targetPath}${direction}方向`
})
} catch (error) {
console.error('[API] 错误:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
/**
* API: 获取服务状态
*/
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'vue-template-service',
sourceDir: VUE_SOURCE_DIR
})
})
/**
* API: 插入设计组件
*
* POST /api/insert-component
* Body: {
* pagePath: string, // Vue文件路径
* componentId: string, // 设计组件ID
* targetPath: string, // 目标元素路径
* direction: string // 插入方向
* }
*/
app.post('/api/insert-component', async (req, res) => {
try {
const { pagePath, componentId, targetPath, direction } = req.body
console.log('[API] 收到插入请求:', {
pagePath,
componentId,
targetPath,
direction
})
// 验证参数
if (!pagePath || !componentId || !targetPath || !direction) {
return res.status(400).json({
success: false,
error: '缺少必要参数'
})
}
// 构建完整文件路径
let normalizedPath = pagePath
const viewsIndex = normalizedPath.indexOf('views/')
if (viewsIndex !== -1) {
normalizedPath = normalizedPath.substring(viewsIndex + 6)
}
const filePath = path.join(VUE_SOURCE_DIR, normalizedPath)
// 读取设计组件模板
const templatePath = path.join(DESIGN_COMPONENTS_DIR, componentId, 'template.html')
let componentTemplate
try {
componentTemplate = await readFile(templatePath, 'utf-8')
} catch (err) {
return res.status(404).json({
success: false,
error: `设计组件模板不存在: ${componentId}`
})
}
console.log('[API] 设计组件模板:', componentTemplate.substring(0, 100) + '...')
// 读取Vue文件
let vueContent
try {
vueContent = await readFile(filePath, 'utf-8')
} catch (err) {
return res.status(404).json({
success: false,
error: `文件不存在: ${pagePath}`
})
}
// 执行插入操作
const result = insertElement(vueContent, {
templateContent: componentTemplate.trim(),
targetPath,
direction
})
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error
})
}
// 写回文件
await writeFile(filePath, result.content, 'utf-8')
console.log('[API] 插入成功')
res.json({
success: true,
message: `已将 ${componentId} 插入到 ${targetPath}${direction}方向`
})
} catch (error) {
console.error('[API] 错误:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
/**
* API: 获取页面元素结构树
*
* GET /api/element-tree?pagePath=xxx
*/
app.get('/api/element-tree', async (req, res) => {
try {
const { pagePath } = req.query
if (!pagePath) {
return res.status(400).json({
success: false,
error: '缺少pagePath参数'
})
}
// 构建完整文件路径
let normalizedPath = pagePath
const viewsIndex = normalizedPath.indexOf('views/')
if (viewsIndex !== -1) {
normalizedPath = normalizedPath.substring(viewsIndex + 6)
}
const filePath = path.join(VUE_SOURCE_DIR, normalizedPath)
console.log('[API] 获取结构树:', filePath)
// 读取Vue文件
let vueContent
try {
vueContent = await readFile(filePath, 'utf-8')
} catch (err) {
return res.status(404).json({
success: false,
error: `文件不存在: ${pagePath}`
})
}
// 解析结构树
const result = parseElementTree(vueContent)
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error
})
}
res.json({
success: true,
tree: result.tree
})
} catch (error) {
console.error('[API] 错误:', error)
res.status(500).json({
success: false,
error: error.message
})
}
})
// 启动服务
app.listen(PORT, () => {
console.log(`🚀 Vue模板服务启动: http://localhost:${PORT}`)
console.log(`📁 源码目录: ${VUE_SOURCE_DIR}`)
})
/**
* API: 获取组件属性
* GET /api/component-props?pagePath=xxx&elementPath=xxx
*/
app.get('/api/component-props', async (req, res) => {
try {
const { pagePath, elementPath } = req.query
if (!pagePath || !elementPath) {
return res.status(400).json({
success: false,
error: '缺少参数'
})
}
let normalizedPath = pagePath
const viewsIndex = normalizedPath.indexOf('views/')
if (viewsIndex !== -1) {
normalizedPath = normalizedPath.substring(viewsIndex + 6)
}
const filePath = path.join(VUE_SOURCE_DIR, normalizedPath)
const vueContent = await readFile(filePath, 'utf-8')
const result = parseComponentProps(vueContent, elementPath)
if (!result.success) {
return res.status(400).json(result)
}
res.json(result)
} catch (error) {
res.status(500).json({ success: false, error: error.message })
}
})
/**
* API: 更新组件属性
* POST /api/update-props
*/
app.post('/api/update-props', async (req, res) => {
try {
const { pagePath, elementPath, updates } = req.body
if (!pagePath || !elementPath || !updates) {
return res.status(400).json({
success: false,
error: '缺少参数'
})
}
let normalizedPath = pagePath
const viewsIndex = normalizedPath.indexOf('views/')
if (viewsIndex !== -1) {
normalizedPath = normalizedPath.substring(viewsIndex + 6)
}
const filePath = path.join(VUE_SOURCE_DIR, normalizedPath)
const vueContent = await readFile(filePath, 'utf-8')
const result = updateComponentProps(vueContent, elementPath, updates)
if (!result.success) {
return res.status(400).json(result)
}
await writeFile(filePath, result.content, 'utf-8')
res.json({ success: true, message: '属性更新成功' })
} catch (error) {
res.status(500).json({ success: false, error: error.message })
}
})
/**
* API: 删除元素
* POST /api/delete-element
*/
app.post('/api/delete-element', async (req, res) => {
try {
const { pagePath, elementPath } = req.body
if (!pagePath || !elementPath) {
return res.status(400).json({
success: false,
error: '缺少参数'
})
}
let normalizedPath = pagePath
const viewsIndex = normalizedPath.indexOf('views/')
if (viewsIndex !== -1) {
normalizedPath = normalizedPath.substring(viewsIndex + 6)
}
const filePath = path.join(VUE_SOURCE_DIR, normalizedPath)
const vueContent = await readFile(filePath, 'utf-8')
const result = deleteElement(vueContent, elementPath)
if (!result.success) {
return res.status(400).json(result)
}
await writeFile(filePath, result.content, 'utf-8')
console.log(`[API] 删除成功: ${elementPath}`)
res.json({ success: true, message: '元素删除成功' })
} catch (error) {
res.status(500).json({ success: false, error: error.message })
}
})

View File

@@ -0,0 +1,883 @@
/**
* Vue模板解析和修改服务
*
* 使用 @vue/compiler-sfc 解析Vue文件
* 使用 @vue/compiler-dom 解析template获取AST
* 通过AST的位置信息定位元素使用字符串操作移动元素保持原始格式
*/
import { parse as parseSFC } from '@vue/compiler-sfc'
import { parse as parseTemplate } from '@vue/compiler-dom'
/**
* 解析路径字符串为路径节点数组
* 例如: "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], // 'r' 或 'c'
index: parseInt(match[2], 10)
})
}
return nodes
}
/**
* 判断节点是否为el-row
*/
function isElRow(node) {
return node.type === 1 && node.tag === 'el-row'
}
/**
* 判断节点是否为el-col
*/
function isElCol(node) {
return node.type === 1 && node.tag === 'el-col'
}
/**
* 根据路径在AST中查找元素
* @param {Object} ast - Vue template AST
* @param {string} pathStr - 路径字符串,如 "r1c2"
* @returns {Object|null} - 找到的AST节点
*/
function findElementByPath(ast, pathStr) {
const pathNodes = parsePath(pathStr)
if (pathNodes.length === 0) return null
// 从template的子节点开始查找
let currentChildren = ast.children
let currentNode = null
for (const pathNode of pathNodes) {
const targetType = pathNode.type === 'r' ? 'el-row' : 'el-col'
const targetIndex = pathNode.index
// 在当前层级查找第N个目标类型的元素
let count = 0
let found = false
for (const child of currentChildren) {
if (child.type !== 1) continue // 跳过非元素节点(文本、注释等)
if (child.tag === targetType) {
count++
if (count === targetIndex) {
currentNode = child
currentChildren = child.children || []
found = true
break
}
}
}
if (!found) {
console.log(`[findElementByPath] 未找到: ${targetType} #${targetIndex}`)
return null
}
}
return currentNode
}
/**
* 获取节点在源码中的完整文本
*/
function getNodeSourceText(source, node) {
if (!node.loc) return null
const start = node.loc.start.offset
const end = node.loc.end.offset
return source.substring(start, end)
}
/**
* 获取字符串的缩进级别(空格数)
*/
function getIndentLevel(str) {
const match = str.match(/^(\s*)/)
return match ? match[1].length : 0
}
/**
* 调整源代码的缩进,保持相对缩进关系
* @param {string} sourceText - 源元素文本
* @param {string} targetIndent - 目标位置的缩进
* @returns {string} - 调整后的文本
*/
function adjustIndentation(sourceText, targetIndent) {
const lines = sourceText.split('\n')
if (lines.length === 0) return sourceText
// 获取第一行的原始缩进
const firstLineIndent = getIndentLevel(lines[0])
// 计算缩进差值
const targetIndentLevel = targetIndent.length
const indentDiff = targetIndentLevel - firstLineIndent
// 对每一行应用缩进差值
const adjustedLines = lines.map((line, index) => {
if (!line.trim()) return '' // 空行保持空
const currentIndent = getIndentLevel(line)
const newIndent = Math.max(0, currentIndent + indentDiff)
return ' '.repeat(newIndent) + line.trimStart()
})
return adjustedLines.join('\n')
}
/**
* 将元素移动到目标元素内部
* 如果目标已有子元素,则放到最后
*/
function moveElementInside(templateContent, sourceNode, targetNode, sourcePath, targetPath) {
const sourceStart = sourceNode.loc.start.offset
const sourceEnd = sourceNode.loc.end.offset
const sourceText = templateContent.substring(sourceStart, sourceEnd)
// 1. 计算删除范围
let deleteStart = sourceStart
let deleteEnd = sourceEnd
// 向前查找这一行的开始
const lineStart = templateContent.lastIndexOf('\n', sourceStart - 1) + 1
const beforeElement = templateContent.substring(lineStart, sourceStart)
// 如果元素前面只有空白,则从行开始删除
if (/^\s*$/.test(beforeElement)) {
deleteStart = lineStart
}
// 向后查找是否有换行
if (templateContent[sourceEnd] === '\n') {
deleteEnd = sourceEnd + 1
}
// 2. 获取目标元素的缩进(子元素需要比父元素多一层缩进)
const targetLineStart = templateContent.lastIndexOf('\n', targetNode.loc.start.offset - 1) + 1
const targetIndent = templateContent.substring(targetLineStart, targetNode.loc.start.offset)
const childIndent = targetIndent + ' '
// 3. 找到目标元素的结束标签位置
const targetText = templateContent.substring(targetNode.loc.start.offset, targetNode.loc.end.offset)
const closeTagPattern = `</${targetNode.tag}>`
const closeTagIndex = targetText.lastIndexOf(closeTagPattern)
if (closeTagIndex === -1) {
console.error('[moveElementInside] 未找到结束标签:', targetNode.tag)
return templateContent
}
// 插入位置(在结束标签之前)
const insertPosition = targetNode.loc.start.offset + closeTagIndex
// 4. 调整源元素的缩进(保持相对缩进)
const adjustedSourceText = adjustIndentation(sourceText, childIndent)
// 构建插入文本
const insertText = adjustedSourceText + '\n' + targetIndent
// 5. 执行操作(从后向前处理)
if (deleteStart > insertPosition) {
// 删除位置在插入位置后面:先删除,再插入
let result = templateContent.substring(0, deleteStart) + templateContent.substring(deleteEnd)
result = result.substring(0, insertPosition) + insertText + result.substring(insertPosition)
return result
} else {
// 删除位置在插入位置前面:先插入,再删除
const deletedLength = deleteEnd - deleteStart
const adjustedInsertPos = insertPosition - deletedLength
let result = templateContent.substring(0, deleteStart) + templateContent.substring(deleteEnd)
result = result.substring(0, adjustedInsertPos) + insertText + result.substring(adjustedInsertPos)
return result
}
}
/**
* 移动元素
*
* @param {string} vueContent - 完整的Vue文件内容
* @param {Object} options - 移动选项
* @param {string} options.sourcePath - 源元素路径,如 "r1c1"
* @param {string} options.targetPath - 目标元素路径,如 "r1c2"
* @param {string} options.direction - 移动方向: 'top' | 'bottom' | 'left' | 'right' | 'inside'
* @returns {Object} - { success: boolean, content?: string, error?: string }
*/
export function moveElement(vueContent, options) {
const { sourcePath, targetPath, direction } = options
try {
// 1. 解析Vue SFC文件
const sfcResult = parseSFC(vueContent)
if (sfcResult.errors.length > 0) {
return {
success: false,
error: `Vue文件解析错误: ${sfcResult.errors[0].message}`
}
}
const templateBlock = sfcResult.descriptor.template
if (!templateBlock) {
return {
success: false,
error: '未找到template块'
}
}
const templateContent = templateBlock.content
// 找到 <template> 内容在原文件中的实际位置
const templateTagStart = vueContent.indexOf('<template')
const templateTagEnd = vueContent.indexOf('>', templateTagStart) + 1
const templateCloseStart = vueContent.indexOf('</template>', templateTagEnd)
const contentStart = templateTagEnd
const contentEnd = templateCloseStart
// 2. 解析template获取AST
const templateAST = parseTemplate(templateContent, {
comments: true,
whitespace: 'preserve'
})
// 3. 查找源元素和目标元素
const sourceNode = findElementByPath(templateAST, sourcePath)
const targetNode = findElementByPath(templateAST, targetPath)
if (!sourceNode) {
return { success: false, error: `未找到源元素: ${sourcePath}` }
}
if (!targetNode) {
return { success: false, error: `未找到目标元素: ${targetPath}` }
}
// 4. 获取元素在template中的精确位置
const sourceStart = sourceNode.loc.start.offset
const sourceEnd = sourceNode.loc.end.offset
const sourceText = templateContent.substring(sourceStart, sourceEnd)
const targetStart = targetNode.loc.start.offset
const targetEnd = targetNode.loc.end.offset
console.log(`[moveElement] 源: ${sourcePath} [${sourceStart}-${sourceEnd}]`)
console.log(`[moveElement] 目标: ${targetPath} [${targetStart}-${targetEnd}]`)
console.log(`[moveElement] 方向: ${direction}`)
// 5. 计算删除范围(包括前面的缩进和后面的换行)
// 找到源元素所在行的开始
let deleteStart = sourceStart
let deleteEnd = sourceEnd
// 向前查找这一行的开始(换行符后的第一个字符)
const lineStart = templateContent.lastIndexOf('\n', sourceStart - 1) + 1
const beforeElement = templateContent.substring(lineStart, sourceStart)
// 如果元素前面只有空白,则从行开始删除
if (/^\s*$/.test(beforeElement)) {
deleteStart = lineStart
}
// 向后查找是否有换行
if (templateContent[sourceEnd] === '\n') {
deleteEnd = sourceEnd + 1
}
// 获取目标元素的缩进
const targetLineStart = templateContent.lastIndexOf('\n', targetStart - 1) + 1
const targetIndent = templateContent.substring(targetLineStart, targetStart)
let newTemplateContent
// 6. 处理 'inside' 方向
if (direction === 'inside') {
newTemplateContent = moveElementInside(
templateContent,
sourceNode,
targetNode,
sourcePath,
targetPath
)
} else {
// 7. 计算插入位置
const insertAfterTarget = (direction === 'bottom' || direction === 'right')
const insertPosition = insertAfterTarget ? targetEnd : targetStart
// 构建插入文本(使用目标缩进,保持相对缩进)
const adjustedSource = adjustIndentation(sourceText, targetIndent)
const insertText = insertAfterTarget
? '\n' + adjustedSource
: adjustedSource + '\n'
// 8. 执行操作(从后向前处理,避免偏移量问题)
if (deleteStart > insertPosition) {
// 删除位置在插入位置后面:先删除,再插入
newTemplateContent = templateContent.substring(0, deleteStart) +
templateContent.substring(deleteEnd)
newTemplateContent = newTemplateContent.substring(0, insertPosition) +
insertText +
newTemplateContent.substring(insertPosition)
} else {
// 删除位置在插入位置前面:先插入,再删除(需要调整偏移)
const deletedLength = deleteEnd - deleteStart
const adjustedInsertPos = insertPosition - deletedLength
newTemplateContent = templateContent.substring(0, deleteStart) +
templateContent.substring(deleteEnd)
newTemplateContent = newTemplateContent.substring(0, adjustedInsertPos) +
insertText +
newTemplateContent.substring(adjustedInsertPos)
}
}
// 9. 重建Vue文件
const newVueContent = vueContent.substring(0, contentStart) +
newTemplateContent +
vueContent.substring(contentEnd)
console.log('[moveElement] 文件更新成功')
return { success: true, content: newVueContent }
} catch (error) {
console.error('[moveElement] 错误:', error)
return { success: false, error: error.message }
}
}
/**
* 获取template的AST用于调试
*/
export function getTemplateAST(vueContent) {
const sfcResult = parseSFC(vueContent)
if (!sfcResult.descriptor.template) {
return null
}
const templateContent = sfcResult.descriptor.template.content
return parseTemplate(templateContent, {
comments: true,
whitespace: 'preserve'
})
}
/**
* 插入新元素到指定位置
*
* @param {string} vueContent - 完整的Vue文件内容
* @param {Object} options - 插入选项
* @param {string} options.templateContent - 要插入的模板内容
* @param {string} options.targetPath - 目标元素路径,如 "r1c2"
* @param {string} options.direction - 插入方向: 'top' | 'bottom' | 'left' | 'right' | 'inside'
* @returns {Object} - { success: boolean, content?: string, error?: string }
*/
/**
* 解析Vue文件的el-row/el-col结构树
* @param {string} vueContent - Vue文件内容
* @returns {Object} - { success: boolean, tree?: Array, error?: string }
*/
export function parseElementTree(vueContent) {
try {
// 1. 解析Vue SFC文件
const sfcResult = parseSFC(vueContent)
if (sfcResult.errors.length > 0) {
return {
success: false,
error: `Vue文件解析错误: ${sfcResult.errors[0].message}`
}
}
const templateBlock = sfcResult.descriptor.template
if (!templateBlock) {
return {
success: false,
error: '未找到template块'
}
}
// 2. 解析template获取AST
const templateAST = parseTemplate(templateBlock.content, {
comments: true,
whitespace: 'preserve'
})
// 3. 递归构建el-row/el-col树
const buildTree = (children, pathPrefix = '') => {
const result = []
let rowIndex = 0
let colIndex = 0
for (const child of children) {
if (child.type !== 1) continue // 跳过非元素节点
if (child.tag === 'el-row') {
rowIndex++
const path = pathPrefix + 'r' + rowIndex
const node = {
type: 'row',
path,
label: 'el-row',
children: buildTree(child.children || [], path)
}
result.push(node)
} else if (child.tag === 'el-col') {
colIndex++
const path = pathPrefix + 'c' + colIndex
// 检查el-col内部的子组件
const componentName = getInnerComponentName(child.children || [])
const spanAttr = child.props?.find(p => p.name === 'span' || (p.name === 'bind' && p.arg?.content === 'span'))
let span = 24
if (spanAttr) {
if (spanAttr.name === 'span' && spanAttr.value) {
span = parseInt(spanAttr.value.content) || 24
} else if (spanAttr.exp) {
span = parseInt(spanAttr.exp.content) || 24
}
}
const node = {
type: 'col',
path,
label: `el-col :span="${span}"`,
componentName,
children: buildTree(child.children || [], path)
}
result.push(node)
}
}
return result
}
// 获取el-col内部的组件名称优先读data-component属性
const getInnerComponentName = (children) => {
for (const child of children) {
if (child.type !== 1) continue
if (child.tag === 'el-row' || child.tag === 'el-col') continue
// 优先检查 data-component 属性
if (child.props) {
const dataComponentAttr = child.props.find(p => p.name === 'data-component')
if (dataComponentAttr?.value?.content) {
return dataComponentAttr.value.content
}
}
// 其他元素返回"其他"
return '其他'
}
return null
}
const tree = buildTree(templateAST.children)
return { success: true, tree }
} catch (error) {
console.error('[parseElementTree] 错误:', error)
return { success: false, error: error.message }
}
}
export function insertElement(vueContent, options) {
const { templateContent: insertContent, targetPath, direction } = options
try {
// 1. 解析Vue SFC文件
const sfcResult = parseSFC(vueContent)
if (sfcResult.errors.length > 0) {
return {
success: false,
error: `Vue文件解析错误: ${sfcResult.errors[0].message}`
}
}
const templateBlock = sfcResult.descriptor.template
if (!templateBlock) {
return {
success: false,
error: '未找到template块'
}
}
const templateContent = templateBlock.content
// 找到 <template> 内容在原文件中的实际位置
const templateTagStart = vueContent.indexOf('<template')
const templateTagEnd = vueContent.indexOf('>', templateTagStart) + 1
const templateCloseStart = vueContent.indexOf('</template>', templateTagEnd)
const contentStart = templateTagEnd
const contentEnd = templateCloseStart
// 2. 解析template获取AST
const templateAST = parseTemplate(templateContent, {
comments: true,
whitespace: 'preserve'
})
// 3. 查找目标元素
const targetNode = findElementByPath(templateAST, targetPath)
if (!targetNode) {
return { success: false, error: `未找到目标元素: ${targetPath}` }
}
// 4. 获取目标元素的位置和缩进
const targetStart = targetNode.loc.start.offset
const targetEnd = targetNode.loc.end.offset
const targetLineStart = templateContent.lastIndexOf('\n', targetStart - 1) + 1
const targetIndent = templateContent.substring(targetLineStart, targetStart)
console.log(`[insertElement] 目标: ${targetPath} [${targetStart}-${targetEnd}]`)
console.log(`[insertElement] 方向: ${direction}`)
let newTemplateContent
// 5. 根据方向插入
if (direction === 'inside') {
// 放入目标元素内部
const targetText = templateContent.substring(targetStart, targetEnd)
const closeTagPattern = `</${targetNode.tag}>`
const closeTagIndex = targetText.lastIndexOf(closeTagPattern)
if (closeTagIndex === -1) {
return { success: false, error: `未找到目标元素的结束标签` }
}
const insertPosition = targetStart + closeTagIndex
const childIndent = targetIndent + ' '
const adjustedContent = adjustIndentation(insertContent, childIndent)
const insertText = adjustedContent + '\n' + targetIndent
newTemplateContent = templateContent.substring(0, insertPosition) +
insertText +
templateContent.substring(insertPosition)
} else {
// 放在目标元素前面或后面
const insertAfterTarget = (direction === 'bottom' || direction === 'right')
const insertPosition = insertAfterTarget ? targetEnd : targetStart
const adjustedContent = adjustIndentation(insertContent, targetIndent)
const insertText = insertAfterTarget
? '\n' + adjustedContent
: adjustedContent + '\n'
newTemplateContent = templateContent.substring(0, insertPosition) +
insertText +
templateContent.substring(insertPosition)
}
// 6. 重建Vue文件
const newVueContent = vueContent.substring(0, contentStart) +
newTemplateContent +
vueContent.substring(contentEnd)
console.log('[insertElement] 插入成功')
return { success: true, content: newVueContent }
} catch (error) {
console.error('[insertElement] 错误:', error)
return { success: false, error: error.message }
}
}
/**
* 解析组件属性值
* @param {string} vueContent - Vue文件内容
* @param {string} elementPath - 元素路径
* @returns {Object} - { success: boolean, props?: Object, componentId?: string, error?: string }
*/
export function parseComponentProps(vueContent, elementPath) {
try {
const sfcResult = parseSFC(vueContent)
if (sfcResult.errors.length > 0) {
return { success: false, error: `Vue文件解析错误` }
}
const templateBlock = sfcResult.descriptor.template
if (!templateBlock) {
return { success: false, error: '未找到template块' }
}
const templateAST = parseTemplate(templateBlock.content, {
comments: true,
whitespace: 'preserve'
})
const targetNode = findElementByPath(templateAST, elementPath)
if (!targetNode) {
return { success: false, error: `未找到元素: ${elementPath}` }
}
// 提取属性
const props = {}
// 提取span属性从el-col
if (targetNode.tag === 'el-col') {
const spanProp = targetNode.props?.find(p =>
p.name === 'span' || (p.name === 'bind' && p.arg?.content === 'span')
)
if (spanProp) {
if (spanProp.name === 'span' && spanProp.value) {
props.span = parseInt(spanProp.value.content) || 24
} else if (spanProp.exp) {
props.span = parseInt(spanProp.exp.content) || 24
}
} else {
props.span = 24
}
}
// 查找内部组件并提取属性
let componentId = null
const extractChildProps = (children) => {
for (const child of children || []) {
if (child.type !== 1) continue
if (child.tag === 'el-row' || child.tag === 'el-col') continue
// 检查 data-component 属性
const dataCompAttr = child.props?.find(p => p.name === 'data-component')
if (dataCompAttr?.value?.content) {
componentId = dataCompAttr.value.content
}
// 提取子元素属性
extractElementProps(child, props)
// 递归提取孩子节点
if (child.children) {
extractChildProps(child.children)
}
}
}
// 提取元素属性
const extractElementProps = (node, result) => {
if (!node.props) return
for (const prop of node.props) {
if (prop.type === 6) { // 普通属性
const key = `${node.tag}:${prop.name}`
result[key] = prop.value?.content || ''
} else if (prop.type === 7 && prop.name === 'bind') { // v-bind 属性
const key = `${node.tag}::${prop.arg?.content}`
result[key] = prop.exp?.content || ''
}
}
}
extractChildProps(targetNode.children)
console.log('[parseComponentProps] 解析属性:', { elementPath, componentId, props })
return { success: true, props, componentId }
} catch (error) {
console.error('[parseComponentProps] 错误:', error)
return { success: false, error: error.message }
}
}
/**
* 更新组件属性值
* @param {string} vueContent - Vue文件内容
* @param {string} elementPath - 元素路径
* @param {Object} updates - 要更新的属性 { key: value }
* @returns {Object} - { success: boolean, content?: string, error?: string }
*/
export function updateComponentProps(vueContent, elementPath, updates) {
try {
const sfcResult = parseSFC(vueContent)
if (sfcResult.errors.length > 0) {
return { success: false, error: `Vue文件解析错误` }
}
const templateBlock = sfcResult.descriptor.template
if (!templateBlock) {
return { success: false, error: '未找到template块' }
}
const templateContent = templateBlock.content
const templateTagStart = vueContent.indexOf('<template')
const templateTagEnd = vueContent.indexOf('>', templateTagStart) + 1
const templateCloseStart = vueContent.indexOf('</template>', templateTagEnd)
const templateAST = parseTemplate(templateContent, {
comments: true,
whitespace: 'preserve'
})
const targetNode = findElementByPath(templateAST, elementPath)
if (!targetNode) {
return { success: false, error: `未找到元素: ${elementPath}` }
}
let newTemplateContent = templateContent
// 处理span更新el-col的:span属性
if (updates.span !== undefined && targetNode.tag === 'el-col') {
const nodeText = templateContent.substring(targetNode.loc.start.offset, targetNode.loc.end.offset)
const spanMatch = nodeText.match(/:span="(\d+)"/)
if (spanMatch) {
const newNodeText = nodeText.replace(/:span="\d+"/, `:span="${updates.span}"`)
newTemplateContent = newTemplateContent.substring(0, targetNode.loc.start.offset) +
newNodeText +
newTemplateContent.substring(targetNode.loc.end.offset)
}
delete updates.span
}
// 处理其他属性更新
for (const [key, value] of Object.entries(updates)) {
const [tagName, attrName] = key.split(':')
if (!tagName || !attrName) continue
// 查找目标元素
const findTargetElement = (node) => {
if (node.type === 1 && node.tag === tagName) return node
if (node.children) {
for (const child of node.children) {
const found = findTargetElement(child)
if (found) return found
}
}
return null
}
const targetEl = findTargetElement(targetNode)
if (!targetEl) continue
const elStart = targetEl.loc.start.offset
const elEnd = targetEl.loc.end.offset
let elText = newTemplateContent.substring(elStart, elEnd)
// 更新属性
const isBinding = attrName.startsWith(':')
const realAttrName = isBinding ? attrName.substring(1) : attrName
const attrRegex = new RegExp(`(${isBinding ? ':' : ''}${realAttrName})="[^"]*"`)
if (value === '' || value === null || value === undefined) {
// 删除属性
elText = elText.replace(attrRegex, '')
elText = elText.replace(/\s+>/g, '>').replace(/\s{2,}/g, ' ')
} else if (attrRegex.test(elText)) {
// 更新属性
elText = elText.replace(attrRegex, `${isBinding ? ':' : ''}${realAttrName}="${value}"`)
} else {
// 添加属性
const tagEndMatch = elText.match(/<[\w-]+/)
if (tagEndMatch) {
const insertPos = tagEndMatch[0].length
elText = elText.substring(0, insertPos) +
` ${isBinding ? ':' : ''}${realAttrName}="${value}"` +
elText.substring(insertPos)
}
}
newTemplateContent = newTemplateContent.substring(0, elStart) +
elText +
newTemplateContent.substring(elEnd)
}
const newVueContent = vueContent.substring(0, templateTagEnd) +
newTemplateContent +
vueContent.substring(templateCloseStart)
console.log('[updateComponentProps] 更新成功')
return { success: true, content: newVueContent }
} catch (error) {
console.error('[updateComponentProps] 错误:', error)
return { success: false, error: error.message }
}
}
/**
* 删除元素
* @param {string} vueContent - Vue文件内容
* @param {string} elementPath - 元素路径
* @returns {Object} - { success: boolean, content?: string, error?: string }
*/
export function deleteElement(vueContent, elementPath) {
try {
const sfcResult = parseSFC(vueContent)
if (sfcResult.errors.length > 0) {
return { success: false, error: `Vue文件解析错误` }
}
const templateBlock = sfcResult.descriptor.template
if (!templateBlock) {
return { success: false, error: '未找到template块' }
}
const templateContent = templateBlock.content
const templateTagStart = vueContent.indexOf('<template')
const templateTagEnd = vueContent.indexOf('>', templateTagStart) + 1
const templateCloseStart = vueContent.indexOf('</template>', templateTagEnd)
const templateAST = parseTemplate(templateContent, {
comments: true,
whitespace: 'preserve'
})
const targetNode = findElementByPath(templateAST, elementPath)
if (!targetNode) {
return { success: false, error: `未找到元素: ${elementPath}` }
}
// 计算删除范围
let deleteStart = targetNode.loc.start.offset
let deleteEnd = targetNode.loc.end.offset
// 向前查找这一行的开始
const lineStart = templateContent.lastIndexOf('\n', deleteStart - 1) + 1
const beforeElement = templateContent.substring(lineStart, deleteStart)
// 如果元素前面只有空白,则从行开始删除
if (/^\s*$/.test(beforeElement)) {
deleteStart = lineStart
}
// 向后查找是否有换行
if (templateContent[deleteEnd] === '\n') {
deleteEnd = deleteEnd + 1
}
const newTemplateContent = templateContent.substring(0, deleteStart) +
templateContent.substring(deleteEnd)
const newVueContent = vueContent.substring(0, templateTagEnd) +
newTemplateContent +
vueContent.substring(templateCloseStart)
console.log(`[deleteElement] 删除成功: ${elementPath}`)
return { success: true, content: newVueContent }
} catch (error) {
console.error('[deleteElement] 错误:', error)
return { success: false, error: error.message }
}
}

241
项目上下文.md Normal file
View File

@@ -0,0 +1,241 @@
# Vue页面可视化设计器 - 项目上下文
> **用途**:用于在新环境快速恢复 AI 协作上下文
> **更新时间**2026-01-20
> **项目路径**`d:/workspace/fauto-design`
---
## 📋 项目概述
这是一个基于 **Vue3 + TypeScript + Vite + Element Plus** 的**可视化页面设计器**通过拖拽操作直接编辑真实的Vue页面源文件。
### 核心特性
1. **直接解析Vue文件** - 动态扫描并渲染`src/views`下的页面
2. **拖拽式设计** - 将设计组件拖拽到页面,支持四个方向放置
3. **源码级修改** - 拖拽操作直接修改Vue源文件支持热更新
4. **智能层级选择** - 键盘方向键切换嵌套元素层级
5. **实时视觉反馈** - 拖拽预览 + 拖放区域显示
6. **元数据编辑** - 点击组件可编辑属性(宽度、尺寸、颜色等)
7. **多种删除方式** - 右键菜单、Del键、删除图标
---
## 🏗️ 项目结构
```
fauto-design/
├── draggable-panels/ # 前端项目
│ ├── src/
│ │ ├── fauto/ # 🔥 设计器核心代码
│ │ │ ├── Designer.vue # 设计器主入口
│ │ │ │
│ │ │ ├── components/ # 基础UI组件
│ │ │ │ ├── Header.vue # 顶部菜单栏
│ │ │ │ ├── Footer.vue # 底部状态栏
│ │ │ │ ├── MainLayout.vue # 三栏布局容器
│ │ │ │ ├── Panel.vue # 面板容器
│ │ │ │ └── Resizer.vue # 面板分隔器
│ │ │ │
│ │ │ ├── materials/ # 🎁 物料组件系统
│ │ │ │ ├── PageManager/ # 页面管理(树形文件选择)
│ │ │ │ ├── DesignComponentList/ # 设计组件列表
│ │ │ │ ├── DesignCenter/ # 设计中心(动态渲染页面)
│ │ │ │ │ ├── index.vue
│ │ │ │ │ ├── InteractiveWrapper.vue # 交互包装器
│ │ │ │ │ ├── DropZone.vue # 拖放区域指示器
│ │ │ │ │ └── DragPreview.vue # 拖拽预览
│ │ │ │ ├── TreeViewer/ # 结构树(页面结构展示)
│ │ │ │ └── DataTable/ # 元数据编辑器
│ │ │ │
│ │ │ ├── designComponents/ # 🎨 设计组件库
│ │ │ │ ├── TextInput/ # 输入框
│ │ │ │ │ ├── index.json # 组件配置+元数据schema
│ │ │ │ │ └── template.html # 组件模板
│ │ │ │ ├── RadioSelect/ # 单选器
│ │ │ │ └── GridTable/ # 表格
│ │ │ │
│ │ │ ├── plugins/ # 🔌 插件系统
│ │ │ │ ├── index.ts # 统一导出
│ │ │ │ ├── interactionStore.ts # 交互事件钩子
│ │ │ │ ├── dragStore.ts # 拖拽状态管理
│ │ │ │ └── pathUtils.ts # 结构化路径工具
│ │ │ │
│ │ │ ├── stores/ # 🗄️ Pinia状态管理
│ │ │ │ ├── panelStore.ts # 面板布局状态
│ │ │ │ ├── designStore.ts # 设计组件元数据
│ │ │ │ └── vueFileStore.ts# Vue文件选择状态
│ │ │ │
│ │ │ └── types/ # 📝 类型定义
│ │ │
│ │ └── views/ # 📄 示例页面
│ │
│ └── vite.config.ts
└── vue-template-service/ # 🖥️ 后端服务
└── src/
├── index.js # Express服务入口
└── services/
└── templateService.js # Vue模板解析与修改服务
```
---
## 🔑 核心技术要点
### 1. **两阶段拖拽机制** ⭐
**第一阶段(源选择)**:鼠标按下开始,收集当前位置的层级节点
**第二阶段(目标选择)**:移动到新位置,选择目标和方向
```typescript
// dragStore.ts
startDragFromCanvas(path, type, element)
enterTargetPhase(element)
confirmDrop(pagePath) API执行移动
```
### 2. **结构化路径ID** ⭐
**格式**`r{n}c{m}r{x}c{y}...`
- `r`: el-row
- `c`: el-col
- 数字同级索引从1开始
**示例**`r1c2r1c3` 表示:
- 第1个el-row → 第2个el-col → 第1个el-row → 第3个el-col
### 3. **设计组件标识** ⭐
设计组件使用 `data-component` 属性标识类型:
```html
<el-col :span="12">
<div class="design-component" data-component="输入框">
<el-form-item label="文本输入">
<el-input v-model="inputValue" />
</el-form-item>
</div>
</el-col>
```
### 4. **元数据Schema定义** ⭐
设计组件的 index.json 定义可编辑属性:
```json
{
"id": "TextInput",
"name": "输入框",
"metadata": {
"span": { "label": "宽度", "type": "number", "min": 1, "max": 24, "target": "el-col", "attr": ":span" },
"label": { "label": "标签", "type": "text", "target": "el-form-item", "attr": "label" },
"placeholder": { "label": "占位符", "type": "text", "target": "el-input", "attr": "placeholder" }
}
}
```
---
## 🔗 后端API
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/move-element` | POST | 移动元素(画布内拖拽) |
| `/api/insert-element` | POST | 插入新元素(设计组件拖入) |
| `/api/delete-element` | POST | 删除元素 |
| `/api/element-tree` | GET | 获取页面结构树 |
| `/api/component-props` | GET | 获取组件属性值 |
| `/api/update-props` | POST | 更新组件属性值 |
---
## 🎯 数据流图
```
┌─────────────────────────────────────────────────────────────────┐
│ 用户操作 │
└───────────────────────────┬─────────────────────────────────────┘
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 页面管理器 │ │ 设计组件列表 │ │ 设计中心 │
│ (选择Vue文件) │ │ (拖拽组件) │ │ (编辑区域) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ Pinia 状态管理 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ vueFileStore │ │ dragStore │ │ designStore │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└───────────────────────────┬───────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ vue-template-service │
│ (Node.js后端服务) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ templateService.js - Vue模板AST解析与修改 │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────┬───────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ Vue源文件 │
│ (src/views/*.vue) │
└───────────────────────────────────────────────────────────────┘
```
---
## 🛠️ 快速开始
### 启动前端
```bash
cd draggable-panels
npm install
npm run dev
# 访问 http://localhost:5173/draggable
```
### 启动后端
```bash
cd vue-template-service
npm install
node src/index.js
# 服务运行在 http://localhost:3001
```
---
## 📝 Vue页面规范
**强制规则**template的第一层级**有且仅有一个el-row**
```vue
<template>
<el-row :gutter="20">
<el-col :span="12">
<div class="design-component" data-component="输入框">
<!-- 内容 -->
</div>
</el-col>
</el-row>
</template>
```
---
## 📚 相关文档
- **详细设计文档**`项目设计文档.md`
- **开发者指南**`DEVELOPMENT.md`
- **README**`README.md`
---
**最后更新**2026-01-20
**AI协作建议**:优先阅读"核心技术要点"和"后端API"部分,理解系统的数据流和交互方式

423
项目设计文档.md Normal file
View File

@@ -0,0 +1,423 @@
# Vue页面可视化设计器 - 项目设计文档
## 1. 项目概述
本项目是一个基于 **Vue3 + TypeScript + Element Plus** 的可视化页面设计器通过拖拽操作直接编辑真实的Vue源文件实现低代码页面快速构建。
### 1.1 核心特性
| 特性 | 描述 |
|------|------|
| 直接解析Vue文件 | 动态渲染真实的.vue页面 |
| 源码级修改 | 拖拽操作直接修改Vue源文件 |
| 智能层级选择 | 键盘方向键切换嵌套元素层级 |
| 元数据编辑 | 可视化编辑组件属性 |
| 多视图联动 | 设计中心、结构树、元数据面板同步选中 |
| 热更新 | 修改后自动刷新预览 |
### 1.2 技术架构
```
┌─────────────────────────────────────────────────────────────────┐
│ 前端应用 (Vue3 + Vite) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 页面管理 │ │设计组件列│ │ 设计中心 │ │ 元数据 │ │
│ │ │ │表 │ │ │ │ 编辑器 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Pinia 状态管理 + 插件系统 │ │
│ │ dragStore │ interactionStore │ designStore │ vueFileStore│ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ HTTP API
┌─────────────────────────────────────────────────────────────────┐
│ 后端服务 (Node.js + Express) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ templateService - Vue模板AST解析与修改 │ │
│ │ @vue/compiler-sfc + @vue/compiler-dom │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ 文件操作
┌─────────────────────────────────────────────────────────────────┐
│ Vue源文件 (src/views/*.vue) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. 核心模块设计
### 2.1 前端模块
#### 2.1.1 设计中心 (DesignCenter)
**职责**动态渲染选中的Vue页面注入交互事件
**核心组件**
- `index.vue` - 主容器动态加载Vue页面组件
- `InteractiveWrapper.vue` - 交互包装器,注入事件监听
- `DropZone.vue` - 拖放区域指示器
- `DragPreview.vue` - 拖拽跟随预览
**交互事件注入流程**
```typescript
// 1. 动态渲染页面组件
<component :is="selectedPageComponent" />
// 2. 挂载后扫描DOM注入事件
const injectInteractionEvents = () => {
document.querySelectorAll('.el-row, .el-col').forEach(el => {
const path = generateElementPath(el) // 生成结构化路径
bindElementEvents(el, type, path) // 绑定交互事件
})
}
// 3. MutationObserver监听DOM变化动态注入
observer.observe(container, { childList: true, subtree: true })
```
#### 2.1.2 结构树 (TreeViewer)
**职责**展示页面的el-row/el-col结构支持选中和删除
**功能**
- 树形展示页面结构
- 点击节点选中对应元素
- 拖拽节点调整顺序
- 右键菜单/删除图标/Del键删除
#### 2.1.3 元数据编辑器 (DataTable)
**职责**:展示和编辑选中组件的属性
**支持的属性类型**
- `number` - 数字输入如span宽度
- `text` - 文本输入如placeholder
- `select` - 下拉选择如size尺寸
- `boolean` - 开关切换如stripe斑马纹
- `color` - 颜色选择器
#### 2.1.4 插件系统 (plugins/)
**dragStore** - 拖拽状态管理
```typescript
interface DragStore {
// 状态
isDragging: boolean
dragPhase: 'source' | 'target' // 两阶段拖拽
dragSource: DragSource | null
hierarchyNodes: HierarchyNode[] // 层级节点列表
selectedHierarchyIndex: number // 当前选中层级
hoverDirection: Direction | null
// 方法
startDragFromCanvas(path, type, element) // 开始画布内拖拽
startDragFromComponentList(id, name, template) // 开始组件列表拖拽
enterTargetPhase(element) // 进入目标选择阶段
selectParentLevel() // 选择父级(↑键)
selectChildLevel() // 选择子级(↓键)
confirmDrop(pagePath) // 确认拖放
}
```
**interactionStore** - 交互事件钩子
```typescript
interface InteractionStore {
hoverTarget: InteractionTarget | null
selectedTarget: InteractionTarget | null
onHover(target)
onClick(target)
onLeave()
}
```
### 2.2 后端模块
#### 2.2.1 templateService
**职责**解析Vue文件执行结构修改
**核心函数**
| 函数 | 功能 |
|------|------|
| `moveElement(vueContent, options)` | 移动元素到新位置 |
| `insertElement(vueContent, options)` | 插入新元素 |
| `deleteElement(vueContent, elementPath)` | 删除元素 |
| `parseElementTree(vueContent)` | 解析页面结构树 |
| `parseComponentProps(vueContent, path)` | 获取组件属性 |
| `updateComponentProps(vueContent, path, updates)` | 更新组件属性 |
**技术实现**
```javascript
// 使用 @vue/compiler-sfc 解析Vue文件
import { parse as parseSFC } from '@vue/compiler-sfc'
// 使用 @vue/compiler-dom 解析template获取AST
import { parse as parseTemplate } from '@vue/compiler-dom'
// 通过AST定位元素使用字符串操作修改保持原始格式
```
---
## 3. 数据结构设计
### 3.1 结构化路径
**格式**`r{n}c{m}r{x}c{y}...`
| 前缀 | 含义 |
|------|------|
| r | el-row |
| c | el-col |
| 数字 | 同级元素中的索引从1开始 |
**示例**`r1c2r1` = 第1个row → 第2个col → 第1个row
### 3.2 设计组件配置
```json
{
"id": "TextInput",
"name": "输入框",
"icon": "✏️",
"description": "用于输入文本内容的表单组件",
"defaultSpan": 12,
"metadata": {
"span": {
"label": "宽度",
"type": "number",
"min": 1,
"max": 24,
"target": "el-col",
"attr": ":span"
},
"placeholder": {
"label": "占位符",
"type": "text",
"target": "el-input",
"attr": "placeholder"
}
}
}
```
### 3.3 设计组件模板
```html
<el-col :span="12">
<div class="design-component design-text-input" data-component="输入框">
<el-form-item label="文本输入">
<el-input v-model="inputValue" placeholder="请输入内容"></el-input>
</el-form-item>
</div>
</el-col>
```
**关键点**
- 外层必须是 `el-col`
- 内层 `div` 必须有 `data-component` 属性标识组件类型
- 使用 `class="design-component"` 标识设计组件
---
## 4. API设计
### 4.1 元素操作API
#### 移动元素
```
POST /api/move-element
Body: {
"pagePath": "D:/workspace/.../TestPage1.vue",
"source": {
"type": "canvas-element",
"path": "r1c1",
"elementType": "ec"
},
"targetPath": "r1c2",
"targetType": "ec",
"direction": "right" // top|bottom|left|right|inside
}
```
#### 插入元素
```
POST /api/insert-element
Body: {
"pagePath": "...",
"templateContent": "<el-col :span=\"12\">...</el-col>",
"targetPath": "r1c1",
"direction": "right"
}
```
#### 删除元素
```
POST /api/delete-element
Body: {
"pagePath": "...",
"elementPath": "r1c2"
}
```
### 4.2 属性操作API
#### 获取组件属性
```
GET /api/component-props?pagePath=...&elementPath=r1c1
Response: {
"success": true,
"componentId": "输入框",
"props": {
"span": 12,
"el-input:placeholder": "请输入"
}
}
```
#### 更新组件属性
```
POST /api/update-props
Body: {
"pagePath": "...",
"elementPath": "r1c1",
"updates": {
"span": 8,
"el-input:placeholder": "新占位符"
}
}
```
### 4.3 结构查询API
#### 获取页面结构树
```
GET /api/element-tree?pagePath=...
Response: {
"success": true,
"tree": [
{
"type": "row",
"path": "r1",
"label": "el-row",
"children": [
{
"type": "col",
"path": "r1c1",
"componentName": "输入框",
"children": []
}
]
}
]
}
```
---
## 5. 交互设计
### 5.1 拖拽流程
```
1. 用户开始拖拽(组件列表/画布元素)
2. 进入源选择阶段,收集层级节点
3. 移动到目标位置,进入目标选择阶段
4. 显示DropZone上/下/左/右/放入)
5. 用户点击方向确认拖放
6. 调用后端API修改源文件
7. 触发vue-template-updated事件
8. 前端刷新页面和结构树
```
### 5.2 层级选择
当鼠标悬停在嵌套元素上时(如`r1c1r1c1`
- **默认选中**最深层级r1c1r1c1
- **↑键**切换到父级r1c1r1 → r1c1 → r1
- **↓键**:切换到子级
- **Esc**:取消拖拽
### 5.3 多视图联动
- 点击**设计中心**元素 → 结构树高亮 + 元数据面板更新
- 点击**结构树**节点 → 设计中心高亮 + 元数据面板更新
- **元数据面板**修改 → 源文件更新 → 设计中心刷新
---
## 6. 扩展指南
### 6.1 添加设计组件
1.`src/fauto/designComponents/` 创建目录
2. 添加 `index.json`(配置+元数据schema
3. 添加 `template.html`(组件模板)
4. 自动在组件列表显示
### 6.2 添加物料组件
1.`src/fauto/materials/` 创建目录
2. 添加 `index.vue`(组件实现)
3. 添加 `index.json`(配置信息)
4.`materials/index.ts` 注册
### 6.3 扩展元数据类型
`designStore.ts``MetadataField` 接口添加新类型,并在 `DataTable/index.vue` 添加对应的编辑控件。
---
## 7. 技术规范
### 7.1 Vue页面规范
```vue
<template>
<!-- 第一层级必须且只能有一个el-row -->
<el-row :gutter="20">
<el-col :span="24">
<!-- 设计组件必须有data-component属性 -->
<div class="design-component" data-component="组件名">
<!-- 组件内容 -->
</div>
</el-col>
</el-row>
</template>
```
### 7.2 代码规范
- TypeScript 严格模式
- Composition API 组织代码
- Props 必须定义类型
- 样式使用 scoped
- 插件代码放 `fauto/plugins/`
---
## 8. 版本信息
- **前端框架**: Vue 3.5.24
- **构建工具**: Vite 7.3.0
- **状态管理**: Pinia 3.0.4
- **UI框架**: Element Plus
- **后端运行时**: Node.js
- **模板解析**: @vue/compiler-sfc, @vue/compiler-dom
---
**文档更新时间**2026-01-20