diff --git a/README.md b/README.md new file mode 100644 index 0000000..ede3dc0 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Fauto Design - Vue页面可视化设计器 + +

+ Vue 3 + TypeScript + Vite + Element Plus + Node.js +

+ +

+ 🤖 本项目完全由 Qoder AI 开发,所有代码均为 AI 生成 +

+ +--- + +## 📖 项目简介 + +**Fauto Design** 是一个基于 Vue3 的**可视化页面设计器**,通过拖拽操作直接编辑真实的 Vue 源文件。与传统低代码平台不同,本项目直接操作 `.vue` 文件,生成的代码即是最终代码。 + +### ✨ 核心特性 + +- 🎯 **直接编辑源码** - 拖拽操作实时修改 Vue 文件,所见即所得 +- 🖱️ **智能拖拽交互** - 两阶段拖拽:先选源元素层级,再选目标位置 +- ⌨️ **键盘层级选择** - ↑↓ 键切换嵌套元素层级,精准定位 +- 🔄 **跨类型拖放** - 支持 el-row/el-col 之间的灵活布局调整 +- 📝 **保持代码格式** - AST 解析 + 智能缩进,保持源码整洁 + +--- + +## 🏗️ 技术架构 + +``` +┌─────────────────────────────────────────────────┐ +│ 前端 (Vue3 + Vite) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────┐ │ +│ │ PageManager │ │DesignCenter│ │DropZone │ │ +│ └─────────────┘ └─────────────┘ └─────────┘ │ +│ ↓ ↓ ↓ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ dragStore (Pinia) │ │ +│ │ 两阶段拖拽 | 层级选择 | 跨类型判断 │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + │ HTTP + ▼ +┌─────────────────────────────────────────────────┐ +│ 后端 (Node.js + Express) │ +│ ┌──────────────────────────────────────────┐ │ +│ │ templateService.js │ │ +│ │ @vue/compiler-sfc | AST解析 | 元素移动 │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + │ + ▼ + Vue 源文件 (*.vue) +``` + +--- + +## 🚀 快速开始 + +### 环境要求 + +- Node.js >= 18.x +- npm >= 9.x + +### 安装依赖 + +```bash +# 前端 +cd draggable-panels +npm install + +# 后端 +cd vue-template-service +npm install +``` + +### 启动服务 + +```bash +# 终端1 - 启动前端 (端口 5173) +cd draggable-panels +npm run dev + +# 终端2 - 启动后端 (端口 3001) +cd vue-template-service +node src/index.js +``` + +### 访问 + +打开浏览器访问:**http://localhost:5173/draggable** + +--- + +## 📁 项目结构 + +``` +fauto-design/ +├── 项目上下文.md # AI 协作上下文文档 +├── 项目设计文档.md # 详细技术设计文档 +├── README.md # 本文件 +│ +├── draggable-panels/ # 前端项目 +│ └── src/ +│ ├── fauto/ # 设计器核心 +│ │ ├── components/ # UI组件 +│ │ ├── materials/ # 物料组件 +│ │ ├── plugins/ # 核心插件 (dragStore等) +│ │ └── stores/ # 状态管理 +│ └── views/ # 示例页面 +│ +└── vue-template-service/ # 后端服务 + └── src/ + └── services/ + └── templateService.js # Vue模板解析修改 +``` + +--- + +## 🎮 使用方法 + +1. **选择页面** - 在左侧面板选择要编辑的 Vue 页面 +2. **开始拖拽** - 点击页面中的 el-row 或 el-col 元素 +3. **选择源层级** - 按 ↑↓ 键切换要移动的层级 +4. **移动到目标** - 拖动到目标元素上 +5. **选择目标层级** - 按 ↑↓ 键切换目标层级 +6. **确认位置** - 松开鼠标,选择放置方向(上/下/左/右/内部) +7. **自动保存** - 源文件自动更新,页面热刷新 + +--- + +## 🤖 关于 AI 开发 + +本项目是一个 **AI 驱动开发**的实验性项目: + +- **开发工具**:Qoder AI(AI 编程助手) +- **代码生成**:100% 由 AI 生成 +- **人工参与**:需求描述、测试反馈、方向指导 + +这证明了 AI 辅助开发在复杂前端项目中的可行性,包括: +- 状态管理设计 +- 拖拽交互实现 +- AST 解析与修改 +- 多阶段工作流 + +--- + +## 📄 相关文档 + +- [项目上下文.md](./项目上下文.md) - AI 协作快速恢复上下文 +- [项目设计文档.md](./项目设计文档.md) - 详细技术设计 + +--- + +## 📜 License + +MIT License + +--- + +

+ 🚀 Powered by Qoder AI +

diff --git a/draggable-panels/README.md b/draggable-panels/README.md deleted file mode 100644 index 33895ab..0000000 --- a/draggable-panels/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vue 3 + TypeScript + Vite - -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` - - - - diff --git a/draggable-panels/src/fauto/designComponents/GridTable/template.html b/draggable-panels/src/fauto/designComponents/GridTable/template.html new file mode 100644 index 0000000..9405b27 --- /dev/null +++ b/draggable-panels/src/fauto/designComponents/GridTable/template.html @@ -0,0 +1,9 @@ + +
+ + + + + +
+
diff --git a/draggable-panels/src/fauto/designComponents/RadioSelect/index.json b/draggable-panels/src/fauto/designComponents/RadioSelect/index.json index 6369b36..ca7667a 100644 --- a/draggable-panels/src/fauto/designComponents/RadioSelect/index.json +++ b/draggable-panels/src/fauto/designComponents/RadioSelect/index.json @@ -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" + } } } diff --git a/draggable-panels/src/fauto/designComponents/RadioSelect/index.vue b/draggable-panels/src/fauto/designComponents/RadioSelect/index.vue deleted file mode 100644 index e6c2b2a..0000000 --- a/draggable-panels/src/fauto/designComponents/RadioSelect/index.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/draggable-panels/src/fauto/designComponents/RadioSelect/template.html b/draggable-panels/src/fauto/designComponents/RadioSelect/template.html new file mode 100644 index 0000000..57e6774 --- /dev/null +++ b/draggable-panels/src/fauto/designComponents/RadioSelect/template.html @@ -0,0 +1,11 @@ + +
+ + + + + + + +
+
diff --git a/draggable-panels/src/fauto/designComponents/TextInput/index.json b/draggable-panels/src/fauto/designComponents/TextInput/index.json index d63ffed..69cd3ed 100644 --- a/draggable-panels/src/fauto/designComponents/TextInput/index.json +++ b/draggable-panels/src/fauto/designComponents/TextInput/index.json @@ -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" + } } } diff --git a/draggable-panels/src/fauto/designComponents/TextInput/index.vue b/draggable-panels/src/fauto/designComponents/TextInput/index.vue deleted file mode 100644 index 968c1eb..0000000 --- a/draggable-panels/src/fauto/designComponents/TextInput/index.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/draggable-panels/src/fauto/designComponents/TextInput/template.html b/draggable-panels/src/fauto/designComponents/TextInput/template.html new file mode 100644 index 0000000..8e6da0e --- /dev/null +++ b/draggable-panels/src/fauto/designComponents/TextInput/template.html @@ -0,0 +1,7 @@ + +
+ + + +
+
diff --git a/draggable-panels/src/fauto/materials/DataTable/index.json b/draggable-panels/src/fauto/materials/DataTable/index.json index 0a688bc..00e6a82 100644 --- a/draggable-panels/src/fauto/materials/DataTable/index.json +++ b/draggable-panels/src/fauto/materials/DataTable/index.json @@ -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": "展示和编辑设计组件的属性" } diff --git a/draggable-panels/src/fauto/materials/DataTable/index.vue b/draggable-panels/src/fauto/materials/DataTable/index.vue index 0276445..efeec0f 100644 --- a/draggable-panels/src/fauto/materials/DataTable/index.vue +++ b/draggable-panels/src/fauto/materials/DataTable/index.vue @@ -1,125 +1,224 @@ diff --git a/draggable-panels/src/fauto/materials/DesignComponentList/index.vue b/draggable-panels/src/fauto/materials/DesignComponentList/index.vue index 87dfea6..65e4e95 100644 --- a/draggable-panels/src/fauto/materials/DesignComponentList/index.vue +++ b/draggable-panels/src/fauto/materials/DesignComponentList/index.vue @@ -30,10 +30,6 @@ onUnmounted(() => { } }) -const handleAddComponent = (componentId: string) => { - designStore.addComponent(componentId) -} - // 鼠标悬停设计组件 const handleMouseEnter = (componentId: string, componentName: string) => { const target: InteractionTarget = { @@ -60,15 +56,13 @@ const handleClick = (componentId: string, componentName: string) => { componentId: componentName } interactionStore.onClick(target) - // 添加组件 - designStore.addComponent(componentId) } -// 鼠标按下 - 开始检测拖拽 +// 鼠标按下 - 开始拖拽 const handleMouseDown = (e: MouseEvent, componentId: string, componentName: string) => { e.preventDefault() - // 立即开始拖拽(不需要长按) + // 立即开始拖拽 draggingId.value = componentId dragStore.startDragFromComponentList(componentId, componentName) } @@ -100,7 +94,7 @@ const handleGlobalMouseUp = () => { @mouseleave="handleMouseLeave" @mousedown="handleMouseDown($event, meta.id, meta.name)" > -
📦
+
{{ meta.icon }}
{{ meta.name }}
{{ meta.description }}
diff --git a/draggable-panels/src/fauto/materials/TreeViewer/index.json b/draggable-panels/src/fauto/materials/TreeViewer/index.json index 840e9ce..2ec2d51 100644 --- a/draggable-panels/src/fauto/materials/TreeViewer/index.json +++ b/draggable-panels/src/fauto/materials/TreeViewer/index.json @@ -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布局结构" } diff --git a/draggable-panels/src/fauto/materials/TreeViewer/index.vue b/draggable-panels/src/fauto/materials/TreeViewer/index.vue index 5718de6..471d9dd 100644 --- a/draggable-panels/src/fauto/materials/TreeViewer/index.vue +++ b/draggable-panels/src/fauto/materials/TreeViewer/index.vue @@ -1,77 +1,394 @@ @@ -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; } diff --git a/draggable-panels/src/fauto/plugins/dragStore.ts b/draggable-panels/src/fauto/plugins/dragStore.ts index 973f137..3908916 100644 --- a/draggable-panels/src/fauto/plugins/dragStore.ts +++ b/draggable-panels/src/fauto/plugins/dragStore.ts @@ -100,9 +100,12 @@ export const useDragStore = defineStore('drag', () => { /** * 开始拖拽(从设计组件列表) + * 设计组件拖动直接进入目标选择阶段 */ const startDragFromComponentList = (componentId: string, componentName: string) => { isDragging.value = true + dragPhase.value = 'target' // 设计组件直接进入目标选择阶段 + confirmedSource.value = null dragSource.value = { type: 'design-component', componentId, @@ -369,8 +372,14 @@ export const useDragStore = defineStore('drag', () => { console.log('[DragStore] 确认拖放:', record) // 如果提供了页面路径,发送API请求到后端 - if (pagePath && record.source.type === 'canvas-element') { - await sendMoveRequest(pagePath, record) + 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 @@ -414,6 +423,43 @@ export const useDragStore = defineStore('drag', () => { } } + /** + * 发送设计组件插入请求到后端服务 + */ + 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 } + } + } + /** * 取消拖拽 */ diff --git a/draggable-panels/src/fauto/stores/designStore.ts b/draggable-panels/src/fauto/stores/designStore.ts index 912dec4..e17a4dd 100644 --- a/draggable-panels/src/fauto/stores/designStore.ts +++ b/draggable-panels/src/fauto/stores/designStore.ts @@ -1,11 +1,35 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, computed } from 'vue' + +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 +} + +// 选中的组件信息 +export interface SelectedComponent { + path: string + componentId: string + componentName: string props: Record } @@ -13,28 +37,39 @@ export const useDesignStore = defineStore('design', () => { // 设计组件元数据缓存 const componentMetas = ref([]) + // 当前选中的组件 + const selectedComponent = ref(null) + // 自动扫描所有设计组件的 .json 配置文件 const designComponentMetaModules = import.meta.glob('../designComponents/*/index.json', { eager: true }) + + // 自动扫描所有设计组件的模板文件 + const designComponentTemplateModules = import.meta.glob('../designComponents/*/template.html', { eager: true, query: '?raw', import: 'default' }) - // 加载设计组件元数据(从本地文件自动扫描) + // 加载设计组件元数据 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 }) } @@ -49,10 +84,68 @@ export const useDesignStore = defineStore('design', () => { 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() + } return { componentMetas, + selectedComponent, + selectedMetadataSchema, + init, loadComponentMetas, - getComponentMeta + getComponentMeta, + getComponentMetaByName, + getComponentTemplate, + selectComponent, + clearSelection } }) diff --git a/draggable-panels/src/views/TestPage1.vue b/draggable-panels/src/views/TestPage1.vue index 7406d0c..3d056e3 100644 --- a/draggable-panels/src/views/TestPage1.vue +++ b/draggable-panels/src/views/TestPage1.vue @@ -1,27 +1,73 @@