This commit is contained in:
2025-12-20 23:16:23 +08:00
parent 2d2bd98c47
commit f858f69daa
43 changed files with 476 additions and 99 deletions

View File

@@ -8,6 +8,12 @@
"title": "设计组件列表", "title": "设计组件列表",
"content": "新窗口内容", "content": "新窗口内容",
"materialId": "DesignComponentList" "materialId": "DesignComponentList"
},
{
"id": "78ikdz5",
"title": "测试组件B",
"content": "新窗口内容",
"materialId": "TestWidget2"
} }
], ],
"activeTabId": "up60643" "activeTabId": "up60643"
@@ -20,6 +26,12 @@
"title": "设计中心", "title": "设计中心",
"content": "新窗口内容", "content": "新窗口内容",
"materialId": "DesignCenter" "materialId": "DesignCenter"
},
{
"id": "rdp9iuv",
"title": "测试组件A",
"content": "新窗口内容",
"materialId": "TestWidget1"
} }
], ],
"activeTabId": "j70ckww" "activeTabId": "j70ckww"
@@ -132,8 +144,8 @@
} }
} }
], ],
"activeTabId": "mxfx11j" "activeTabId": "vrh9bl2"
} }
}, },
"lastUpdated": "2025-12-20T13:24:03.895Z" "lastUpdated": "2025-12-20T14:50:20.652Z"
} }

View File

@@ -12,6 +12,16 @@
] ]
} }
}, },
{
"id": "nx1ns6t",
"componentId": "TextInput",
"name": "文本输入框 1",
"props": {
"label": "标签111",
"width": 500,
"maxLength": 100
}
},
{ {
"id": "xazr6j9", "id": "xazr6j9",
"componentId": "GridTable", "componentId": "GridTable",
@@ -30,16 +40,20 @@
} }
}, },
{ {
"id": "nx1ns6t", "id": "sc9viyu",
"componentId": "TextInput", "componentId": "GridTable",
"name": "文本输入框 1", "name": "表格 2",
"props": { "props": {
"label": "标签名称", "rows": 3,
"width": 500, "columns": 3,
"maxLength": 100 "headers": [
"列1",
"列2",
"列3"
]
} }
} }
], ],
"selectedId": "xazr6j9", "selectedId": "xazr6j9",
"lastUpdated": "2025-12-20T13:24:06.541Z" "lastUpdated": "2025-12-20T14:50:43.062Z"
} }

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -1563,6 +1564,27 @@
} }
} }
}, },
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.8.tgz", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.8.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,34 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' // App.vue 现在只作为路由容器
import { usePanelStore } from './stores/panelStore'
import { useDesignStore } from './stores/designStore'
import Header from './components/Header.vue'
import Footer from './components/Footer.vue'
import MainLayout from './components/MainLayout.vue'
const panelStore = usePanelStore()
const designStore = useDesignStore()
onMounted(async () => {
await panelStore.loadConfig()
await designStore.init()
})
</script> </script>
<template> <template>
<div class="app-container"> <router-view />
<Header />
<MainLayout />
<Footer />
</div>
</template> </template>
<style scoped> <style scoped>
.app-container { /* 全局样式已在 style.css 中定义 */
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
</style> </style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { usePanelStore } from './stores/panelStore'
import { useDesignStore } from './stores/designStore'
import Header from './components/Header.vue'
import Footer from './components/Footer.vue'
import MainLayout from './components/MainLayout.vue'
const panelStore = usePanelStore()
const designStore = useDesignStore()
onMounted(async () => {
await panelStore.loadConfig()
await designStore.init()
})
</script>
<template>
<div class="designer-container">
<Header />
<MainLayout />
<Footer />
</div>
</template>
<style scoped>
.designer-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
</style>

View File

@@ -1,15 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent, markRaw } from 'vue' import { defineAsyncComponent, markRaw } from 'vue'
import { useDesignStore } from '../../stores/designStore' import { useDesignStore } from '../../stores/designStore'
import config from './index.json' import config from './index.json'
const designStore = useDesignStore() const designStore = useDesignStore()
// // eager
const designComponentMap: Record<string, any> = { const designComponentModules = import.meta.glob('../../designComponents/*/index.vue', { eager: true })
TextInput: markRaw(defineAsyncComponent(() => import('../../designComponents/TextInput/index.vue'))),
RadioSelect: markRaw(defineAsyncComponent(() => import('../../designComponents/RadioSelect/index.vue'))), //
GridTable: markRaw(defineAsyncComponent(() => import('../../designComponents/GridTable/index.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)
}
} }
const getComponent = (componentId: string) => { const getComponent = (componentId: string) => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, watch } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { useDesignStore } from '../../stores/designStore' import { useDesignStore } from '../../stores/designStore'
import config from './index.json' import config from './index.json'
@@ -10,13 +10,13 @@ const designStore = useDesignStore()
const localComponents = ref([...designStore.components]) const localComponents = ref([...designStore.components])
// designStore // designStore
const unsubscribe = designStore.$subscribe((mutation, state) => { watch(
// () => designStore.components,
if (mutation.type === 'patchObject' && (newVal) => {
(mutation.payload.components || mutation.events.some(e => e.path.includes('components')))) { localComponents.value = [...newVal]
localComponents.value = [...designStore.components] },
} { deep: true, immediate: true }
}) )
// - designStore // - designStore
const onDragEnd = () => { const onDragEnd = () => {

View File

@@ -0,0 +1,43 @@
import { defineAsyncComponent, type Component } from 'vue'
import type { MaterialInfo } from '../types'
// 自动扫描所有物料组件的 .vue 文件
const vueModules = import.meta.glob('./*/index.vue')
// 自动扫描所有物料组件的 .json 配置文件
const jsonModules = import.meta.glob('./*/index.json', { eager: true })
// 自动构建物料组件映射表
export const materialComponents: Record<string, Component> = {}
for (const path in vueModules) {
// 从路径中提取组件 ID例如 './TextEditor/index.vue' => 'TextEditor'
const match = path.match(/\.\/(.+)\/index\.vue$/)
if (match) {
const id = match[1]
materialComponents[id] = defineAsyncComponent(vueModules[path] as any)
}
}
// 自动构建物料信息列表
export const materialList: MaterialInfo[] = []
for (const path in jsonModules) {
// 从路径中提取组件 ID
const match = path.match(/\.\/(.+)\/index\.json$/)
if (match) {
const id = match[1]
const config = (jsonModules[path] as any).default || jsonModules[path]
materialList.push({ id, ...config })
}
}
// 获取物料组件
export const getMaterialComponent = (id: string): Component | undefined => {
return materialComponents[id]
}
// 获取物料信息
export const getMaterialInfo = (id: string): MaterialInfo | undefined => {
return materialList.find(m => m.id === id)
}

View File

@@ -30,6 +30,9 @@ export const useDesignStore = defineStore('design', () => {
// 设计组件元数据缓存 // 设计组件元数据缓存
const componentMetas = ref<DesignComponentMeta[]>([]) const componentMetas = ref<DesignComponentMeta[]>([])
// 自动扫描所有设计组件的 .json 配置文件
const designComponentMetaModules = import.meta.glob('../designComponents/*/index.json', { eager: true })
// 是否已加载 // 是否已加载
const isLoaded = ref(false) const isLoaded = ref(false)
@@ -45,13 +48,29 @@ export const useDesignStore = defineStore('design', () => {
return componentMetas.value.find(m => m.id === selectedComponent.value!.componentId) || null return componentMetas.value.find(m => m.id === selectedComponent.value!.componentId) || null
}) })
// 加载设计组件元数据 // 加载设计组件元数据(从本地文件自动扫描)
const loadComponentMetas = async () => { const loadComponentMetas = async () => {
try { try {
const response = await fetch('/api/design-components') const metas: DesignComponentMeta[] = []
if (response.ok) {
componentMetas.value = await response.json() 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 mod = designComponentMetaModules[path] as any
const config = mod.default || mod
metas.push({
id,
name: config.name,
description: config.description,
props: config.props || {}
})
} }
componentMetas.value = metas
} catch (error) { } catch (error) {
console.error('加载设计组件元数据失败:', error) console.error('加载设计组件元数据失败:', error)
} }

View File

@@ -2,9 +2,11 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
app.use(router)
app.mount('#app') app.mount('#app')

View File

@@ -1,46 +0,0 @@
import { defineAsyncComponent, type Component } from 'vue'
import type { MaterialInfo } from '../types'
// 导入所有物料配置
import TextEditorConfig from './TextEditor/index.json'
import TreeViewerConfig from './TreeViewer/index.json'
import DataTableConfig from './DataTable/index.json'
import TestWidget1Config from './TestWidget1/index.json'
import TestWidget2Config from './TestWidget2/index.json'
import TestWidget3Config from './TestWidget3/index.json'
import DesignComponentListConfig from './DesignComponentList/index.json'
import DesignCenterConfig from './DesignCenter/index.json'
// 物料组件映射表
export const materialComponents: Record<string, Component> = {
TextEditor: defineAsyncComponent(() => import('./TextEditor/index.vue')),
TreeViewer: defineAsyncComponent(() => import('./TreeViewer/index.vue')),
DataTable: defineAsyncComponent(() => import('./DataTable/index.vue')),
TestWidget1: defineAsyncComponent(() => import('./TestWidget1/index.vue')),
TestWidget2: defineAsyncComponent(() => import('./TestWidget2/index.vue')),
TestWidget3: defineAsyncComponent(() => import('./TestWidget3/index.vue')),
DesignComponentList: defineAsyncComponent(() => import('./DesignComponentList/index.vue')),
DesignCenter: defineAsyncComponent(() => import('./DesignCenter/index.vue')),
}
// 物料信息列表
export const materialList: MaterialInfo[] = [
{ id: 'TextEditor', ...TextEditorConfig },
{ id: 'TreeViewer', ...TreeViewerConfig },
{ id: 'DataTable', ...DataTableConfig },
{ id: 'TestWidget1', ...TestWidget1Config },
{ id: 'TestWidget2', ...TestWidget2Config },
{ id: 'TestWidget3', ...TestWidget3Config },
{ id: 'DesignComponentList', ...DesignComponentListConfig },
{ id: 'DesignCenter', ...DesignCenterConfig },
]
// 获取物料组件
export const getMaterialComponent = (id: string): Component | undefined => {
return materialComponents[id]
}
// 获取物料信息
export const getMaterialInfo = (id: string): MaterialInfo | undefined => {
return materialList.find(m => m.id === id)
}

View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import HelloWorld from './views/HelloWorld.vue'
import Designer from './fauto/Designer.vue'
const routes = [
{
path: '/',
name: 'Home',
component: HelloWorld
},
{
path: '/draggable',
name: 'Designer',
component: Designer
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue'
const msg = ref('欢迎使用拖拽设计器')
</script>
<template>
<div class="hello-world">
<h1>{{ msg }}</h1>
<div class="card">
<p>这是一个基于 Vue3 + TypeScript 的可拖拽子窗口设计器项目</p>
<p class="hint">访问 <router-link to="/draggable">/draggable</router-link> 进入设计器</p>
</div>
</div>
</template>
<style scoped>
.hello-world {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
}
h1 {
font-size: 3rem;
margin-bottom: 2rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 2rem 3rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.card p {
font-size: 1.2rem;
line-height: 1.8;
margin: 1rem 0;
}
.hint {
margin-top: 2rem;
font-size: 1rem;
opacity: 0.9;
}
.hint a {
color: #ffd700;
text-decoration: none;
font-weight: bold;
border-bottom: 2px solid #ffd700;
transition: all 0.3s;
}
.hint a:hover {
color: #ffed4e;
border-color: #ffed4e;
}
</style>

View File

@@ -0,0 +1,199 @@
# 可拖拽子窗口项目设计文档
## 项目概述
本项目是一个基于 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. 设计系统支持更复杂的属性编辑器
## 总结
本项目实现了完整的可拖拽子窗口系统,具备良好的架构设计和扩展性。通过物料组件系统和设计组件系统的分离,既满足了基础的窗口管理需求,又提供了高级的设计能力。状态持久化机制确保了用户体验的连续性,而规范化的开发流程保证了项目的可维护性。