Compare commits
13 Commits
f858f69daa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f7059d88f | |||
| 842a132ec6 | |||
| bfa4e3107f | |||
| 4a90340ab3 | |||
| 1bf26e6e71 | |||
| ad2322b553 | |||
| 378fb65c76 | |||
| 9829b91321 | |||
| ff8a6a28f8 | |||
| 3c38f1bee9 | |||
| c6077ff2ad | |||
| 83bafa4e1e | |||
| 7d2ce711dd |
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
6
.idea/MarsCodeWorkspaceAppSettings.xml
generated
Normal 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
9
.idea/fauto-design.iml
generated
Normal 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
6
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
597
DEVELOPMENT.md
Normal 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
21
LICENSE
Normal 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
212
README.md
Normal 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
|
||||
@@ -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) - 开发指南
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
"property": "框架",
|
||||
"value": "9999"
|
||||
},
|
||||
{
|
||||
"property": "语言",
|
||||
"value": "TypeScript"
|
||||
},
|
||||
{
|
||||
"property": "构建工具",
|
||||
"value": "Vite"
|
||||
},
|
||||
{
|
||||
"property": "状态管理",
|
||||
"value": "Pinia"
|
||||
},
|
||||
{
|
||||
"property": "版本",
|
||||
"value": "1.0.0"
|
||||
},
|
||||
{
|
||||
"property": "作者",
|
||||
"value": "Developer"
|
||||
},
|
||||
{
|
||||
"property": "许可证",
|
||||
"value": "MIT"
|
||||
}
|
||||
]
|
||||
}
|
||||
"materialId": "DataTable"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
236
draggable-panels/package-lock.json
generated
236
draggable-panels/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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": "展示和编辑设计组件的属性"
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
|
||||
return Object.entries(comp.props).map(([key, value]) => ({
|
||||
key,
|
||||
value: formatValue(value),
|
||||
rawValue: value
|
||||
}))
|
||||
})
|
||||
// 更新中状态
|
||||
const updating = ref(false)
|
||||
|
||||
// 格式化显示值
|
||||
const formatValue = (value: any): string => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ')
|
||||
// 初始化本地值
|
||||
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 = {}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
// 开始编辑
|
||||
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 updateProp = async (key: string, value: any) => {
|
||||
if (!designStore.selectedComponent || !vueFileStore.selectedFilePath) return
|
||||
|
||||
updating.value = true
|
||||
|
||||
try {
|
||||
const updates: Record<string, any> = {}
|
||||
|
||||
// 如果原始值是数组,尝试解析为数组
|
||||
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)
|
||||
// 根据元数据 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
|
||||
}
|
||||
}
|
||||
|
||||
designStore.updateComponentProps(designStore.selectedId, key, newValue)
|
||||
editingCell.value = null
|
||||
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 }
|
||||
}))
|
||||
} else {
|
||||
console.error('[元数据] 更新失败:', result.error)
|
||||
}
|
||||
} 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 {
|
||||
|
||||
@@ -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>
|
||||
198
draggable-panels/src/fauto/materials/DesignCenter/DropZone.vue
Normal file
198
draggable-panels/src/fauto/materials/DesignCenter/DropZone.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "设计中心",
|
||||
"description": "展示已添加的设计组件实例"
|
||||
"description": "实时预览选中的Vue页面或设计组件"
|
||||
}
|
||||
|
||||
@@ -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 loader = viewModules[vueFileStore.selectedFilePath]
|
||||
if (loader) {
|
||||
return defineAsyncComponent(loader as any)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const getComponent = (componentId: string) => {
|
||||
return designComponentMap[componentId]
|
||||
}
|
||||
|
||||
const handleSelect = (instanceId: string) => {
|
||||
designStore.selectComponent(instanceId)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "页面管理",
|
||||
"description": "浏览和管理src/views目录下的Vue页面文件"
|
||||
}
|
||||
257
draggable-panels/src/fauto/materials/PageManager/index.vue
Normal file
257
draggable-panels/src/fauto/materials/PageManager/index.vue
Normal 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>
|
||||
@@ -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布局结构"
|
||||
}
|
||||
|
||||
@@ -1,77 +1,394 @@
|
||||
<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"
|
||||
<div v-if="loading" class="loading-tip">加载中...</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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="localComponents.length === 0" class="empty-tip">
|
||||
暂无设计组件
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
555
draggable-panels/src/fauto/plugins/dragStore.ts
Normal file
555
draggable-panels/src/fauto/plugins/dragStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
36
draggable-panels/src/fauto/plugins/index.ts
Normal file
36
draggable-panels/src/fauto/plugins/index.ts
Normal 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'
|
||||
257
draggable-panels/src/fauto/plugins/interactionStore.ts
Normal file
257
draggable-panels/src/fauto/plugins/interactionStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
125
draggable-panels/src/fauto/plugins/pathUtils.ts
Normal file
125
draggable-panels/src/fauto/plugins/pathUtils.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "../../../.."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
158
draggable-panels/src/fauto/stores/vueFileStore.ts
Normal file
158
draggable-panels/src/fauto/stores/vueFileStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
92
draggable-panels/src/views/TestPage1.vue
Normal file
92
draggable-panels/src/views/TestPage1.vue
Normal 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>
|
||||
53
draggable-panels/src/views/TestPage2.vue
Normal file
53
draggable-panels/src/views/TestPage2.vue
Normal 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>
|
||||
84
draggable-panels/src/views/dashboard/Overview.vue
Normal file
84
draggable-panels/src/views/dashboard/Overview.vue
Normal 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>
|
||||
70
draggable-panels/src/views/user/Profile.vue
Normal file
70
draggable-panels/src/views/user/Profile.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
draggable-panels/vue-file-selection.json
Normal file
4
draggable-panels/vue-file-selection.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"path": "../../../views/TestPage1.vue",
|
||||
"name": "TestPage1.vue"
|
||||
}
|
||||
@@ -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
20
vue-template-service/.gitignore
vendored
Normal 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
1067
vue-template-service/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
vue-template-service/package.json
Normal file
19
vue-template-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
408
vue-template-service/src/index.js
Normal file
408
vue-template-service/src/index.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
883
vue-template-service/src/services/templateService.js
Normal file
883
vue-template-service/src/services/templateService.js
Normal 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
241
项目上下文.md
Normal 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
423
项目设计文档.md
Normal 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
|
||||
Reference in New Issue
Block a user