代码展示
v-ui-pro
package.json
1{
2 "name": "v-ui-pro",
3 "private": true,
4 "version": "0.0.0",
5 "type": "module",
6 "scripts": {
7 "dev": "vite",
8 "build": "vue-tsc -b && vite build",
9 "preview": "vite preview"
10 },
11 "dependencies": {
12 "@arco-design/web-vue": "^2.57.0",
13 "@vueuse/core": "^13.6.0",
14 "animate.css": "^4.1.1",
15 "grid-layout-plus": "^1.1.0",
16 "less": "^4.4.0",
17 "lodash": "^4.17.21",
18 "vue": "^3.5.17"
19 },
20 "devDependencies": {
21 "@vitejs/plugin-vue": "^6.0.0",
22 "@vue/tsconfig": "^0.7.0",
23 "typescript": "~5.8.3",
24 "vite": "^7.0.4",
25 "vue-tsc": "^2.2.12"
26 }
27}
28main.ts
1
2import {createApp} from 'vue'
3import ArcoVue from '@arco-design/web-vue';
4import ArcoVueIcon from "@arco-design/web-vue/es/icon";
5import App from './App.vue';
6import '@arco-design/web-vue/dist/arco.css';
7import 'animate.css';
8
9const app = createApp(App);
10app.use(ArcoVue);
11app.use(ArcoVueIcon)
12app.mount('#app');
13
14// 全局禁止 Vue 的 warn 打印
15app.config.warnHandler = () => {
16 // 什么都不做,相当于吞掉 warning
17}
18vue
1<template>
2 <div class="layout-demo">
3 <a-layout style="height: 100vh">
4 <a-layout-header style="height: 5vh;padding-right: 5vw">
5 <div
6 style="width: 100%;
7 height: 100%;gap:5px;display: flex;justify-content: end;
8 align-items: center;">
9 <div class="nav-item">
10 <a-switch v-model="draggable">
11 <template #checked>
12 拖动
13 </template>
14 <template #unchecked>
15 停止
16 </template>
17 </a-switch>
18 </div>
19 <div class="nav-item" style="color: #1a1a1a">
20 <a-button shape="circle" @click="handleDark">
21 <template #icon>
22 <icon-sun v-if="dark"/>
23 <icon-moon v-else/>
24 </template>
25 </a-button>
26 </div>
27 </div>
28 </a-layout-header>
29 <a-layout>
30 <a-layout-sider :resize-directions="['right']" style="height: 95vh;">
31 <div class="component-categories">
32 <div class="category" v-for="dir in Object.keys(componentGroups)" :key="dir">
33 <h3 class="category-title">{{ dir }}</h3>
34 <div class="component-list">
35 <div class="component-item" v-for="item in componentGroups[dir]" draggable="true"
36 @dragstart="dragStart(item)" @dragend="dragEnd(item)">
37 <div class="component-preview">
38 <span class="component-name">{{ item.name }}</span>
39 </div>
40 </div>
41 </div>
42 </div>
43 </div>
44 </a-layout-sider>
45 <a-layout-content ref="dropZoneRef">
46 <GridLayout v-if="layout.length>0" style="height: 100%;width: 100%" v-model:layout="layout"
47 :col-num="colNum"
48 :row-height="30"
49 :is-draggable="draggable"
50 :is-resizable="resizable"
51 vertical-compact
52 use-css-transforms
53 ref="gridLayout"
54 drag-allow-from=".vue-draggable-handle"
55 >
56 <template #item="{ item }">
57 <a-dropdown trigger="contextMenu" alignPoint :style="{display:'block'}">
58 <component :is="loadComponent((item as any).filePath)"/>
59 <template #content>
60 <a-doption @click="deleteAction(item)">
61 <template #icon>
62 <icon-delete/>
63 </template>
64 删除
65 </a-doption>
66 <a-doption>
67 <template #icon>
68 <icon-copy/>
69 </template>
70 复制代码
71 </a-doption>
72 </template>
73 </a-dropdown>
74 </template>
75 </GridLayout>
76 <div v-else class="placeholder-content">
77 <div class="placeholder-icon">?</div>
78 <h3>开始设计</h3>
79 <p>从左侧拖拽组件到这里开始创建您的界面</p>
80 </div>
81 </a-layout-content>
82 </a-layout>
83 </a-layout>
84 </div>
85</template>
86
87<script setup lang="ts">
88import type {GridItemProps} from 'grid-layout-plus';
89import {GridLayout} from 'grid-layout-plus'
90import {type Component, ref, defineAsyncComponent, watch} from "vue";
91import {useDropZone, useStorage} from '@vueuse/core'
92
93const dark = ref(false);
94
95const handleDark = () => {
96 dark.value = !dark.value;
97 if (dark.value) {
98// 设置为暗黑主题
99 document.body.setAttribute('arco-theme', 'dark')
100 } else {
101// 恢复亮色主题
102 document.body.removeAttribute('arco-theme');
103 }
104}
105
106interface ComponentItem extends GridItemProps {
107 name?: string,
108 dirName?: string,
109 filePath?: string,
110 component?: any
111}
112
113type ComponentGroup = Record<string, Partial<ComponentItem>[]>;
114// 分组结果
115const componentGroups: ComponentGroup = {};
116
117const currentDropComponentItem = ref<ComponentItem>();
118
119// 缓存已创建的异步组件(按 filePath 缓存包装器)
120const asyncComponentCache = new Map<string, ReturnType<typeof defineAsyncComponent>>()
121
122// 缓存 import() 的 Promise(避免重复请求)
123const importPromiseCache = new Map<string, Promise<any>>()
124
125const loadComponent = (filePath: string) => {
126 // 1. 如果已经创建过这个异步组件,直接返回缓存的
127 if (asyncComponentCache.has(filePath)) {
128 return asyncComponentCache.get(filePath)!
129 }
130
131 // 2. 如果 import() 的 Promise 已存在,复用它(避免重复加载)
132 let importPromise = importPromiseCache.get(filePath)
133 if (!importPromise) {
134 importPromise = import(/* @vite-ignore */ filePath).catch(err => {
135 console.error(`Failed to load component: ${filePath}`, err)
136 // 清除失败的 promise,下次可重试
137 importPromiseCache.delete(filePath)
138 throw err
139 })
140 importPromiseCache.set(filePath, importPromise)
141 }
142
143 // 3. 创建异步组件包装器
144 const asyncComponent = defineAsyncComponent(() => {
145 return importPromise!.then(module => {
146 // 可以处理 module.default 或其他导出
147 return module.default || module
148 })
149 })
150
151 // 4. 缓存包装器,下次直接复用
152 asyncComponentCache.set(filePath, asyncComponent)
153
154 return asyncComponent
155}
156
157const modules = import.meta.glob('./components/*/*.vue', {eager: true});
158
159// 明确告诉 TypeScript:每个模块都有一个 default 导出(Vue 组件)
160const typedModules = modules as Record<string, { default: Component }>;
161
162for (const filePath in typedModules) {
163 // ✅ 提取文件夹名(即 dirName)
164 const dirName = filePath.split('/').slice(-2)[0]; // 取倒数第二段
165 const name = filePath.split('/').pop()?.replace(/.w+$/, '') || ''; // 如 Button.vue → Button
166 const item: Partial<ComponentItem> = {
167 name,
168 dirName,
169 filePath,
170 };
171 if (!componentGroups[dirName]) {
172 componentGroups[dirName] = [];
173 }
174 componentGroups[dirName].push(item);
175}
176
177console.log(componentGroups)
178
179const colNum = ref(12)
180
181const layout = useStorage<ComponentItem[]>('layout', [])
182
183const draggable = ref(true)
184const resizable = ref(true)
185
186// const itemBg = ref('#fff')
187
188const itemBorder = ref('2px dashed #cbd5e1')
189
190watch(() => draggable.value, nv => {
191 if (nv) {
192 itemBorder.value = '2px dashed #cbd5e1'
193 resizable.value = true;
194 } else {
195 itemBorder.value = ''
196 resizable.value = false;
197 }
198})
199
200
201const gridLayout = ref<InstanceType<typeof GridLayout>>()
202
203
204const dropZoneRef = ref<HTMLDivElement>()
205
206
207function onDrop() {
208 // called when files are dropped on zone
209 // ✅ 3. 生成唯一 ID
210 const id = `comp-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`
211 // ✅ 4. 添加到布局
212 layout.value.push({
213 x: 0,
214 y: 0,
215 w: 4, // 可配置
216 h: 4,
217 i: id,
218 ...currentDropComponentItem.value
219 })
220}
221
222useDropZone(dropZoneRef, {
223 onDrop,
224 // specify the types of data to be received.
225 // control multi-file drop
226 multiple: false,
227 // whether to prevent default behavior for unhandled events
228 preventDefaultForUnhandled: false,
229})
230// const backgroundImage = ref('linear-gradient(to right, lightgrey 1px, transparent 1px), linear-gradient(to bottom, lightgrey 1px, transparent 1px)')
231
232const dragStart = (item: ComponentItem | any) => {
233 currentDropComponentItem.value = item;
234}
235const dragEnd = (item: ComponentItem | any) => {
236 currentDropComponentItem.value = item;
237}
238
239const deleteAction = (item: ComponentItem) => {
240 let number = layout.value.findIndex(x => x.i === item.i);
241 layout.value.splice(number, 1);
242}
243
244</script>
245
246<style lang="less" scoped>
247.layout-demo {
248 height: 100vh;
249}
250
251.layout-demo :deep(.arco-layout-header),
252.layout-demo :deep(.arco-layout-footer),
253.layout-demo :deep(.arco-layout-sider-children),
254.layout-demo :deep(.arco-layout-content) {
255 display: flex;
256 flex-direction: column;
257 justify-content: center;
258 color: var(--color-white);
259 font-size: 16px;
260 font-stretch: condensed;
261 text-align: center;
262}
263
264
265.layout-demo :deep(.arco-layout-header),
266.layout-demo :deep(.arco-layout-footer) {
267 height: 64px;
268 //background-color: var(--color-primary-light-4);
269}
270
271.layout-demo :deep(.arco-layout-sider) {
272 width: 206px;
273 //background-color: var(--color-primary-light-3);
274 min-width: 150px;
275 max-width: 500px;
276 height: 200px;
277}
278
279.layout-demo :deep(.arco-layout-content) {
280 background-color: var(--color-neutral-3);
281 position: relative;
282}
283
284.vgl-layout {
285 background-color: #eee;
286 padding: 0;
287}
288
289//.vgl-layout::before {
290// position: absolute;
291// left: 0;
292// top: 0;
293// bottom: 0;
294// right: 0;
295// width: calc(100vw - 5px);
296// height: calc(100vh - 5px);
297// margin: 5px;
298// content: '';
299// //width: 100vw;
300// //height: 100vw;
301// background-image: linear-gradient(to right, lightgrey 1px, transparent 1px), linear-gradient(to bottom, lightgrey 1px, transparent 1px);
302// background-repeat: repeat;
303// background-size: calc(calc(100% - 5px) / 12) 40px;
304//}
305
306.vgl-layout::before {
307 position: absolute;
308 left: 0;
309 top: 0;
310 bottom: 0;
311 right: 0;
312 width: calc(100vw);
313 height: calc(100vh);
314 margin: 5px;
315 content: '';
316 //background-image: linear-gradient(to right, lightgrey 1px, transparent 1px),
317 //linear-gradient(to bottom, lightgrey 1px, transparent 1px);
318 //background-repeat: repeat;
319 //background-size: calc(calc(100vw) / 12) 40px;
320}
321
322:deep(.vgl-item) {
323 overflow: hidden;
324}
325
326:deep(.vgl-item:not(.vgl-item--placeholder)) {
327 //background-color: v-bind(itemBg);
328 border: v-bind(itemBorder);
329}
330
331:deep(.vgl-item:not(.vgl-item--placeholder):hover) {
332 border-color: #94a3b8;
333 background: rgba(241, 245, 249, 0.5);
334}
335
336
337/* 组件分类 */
338.component-categories {
339 flex: 1;
340 overflow-y: auto;
341 padding: 1rem;
342}
343
344.category {
345 margin-bottom: 2rem;
346}
347
348.category-title {
349 font-size: 0.875rem;
350 font-weight: 600;
351 color: #6b7280;
352 text-transform: uppercase;
353 letter-spacing: 0.05em;
354 margin-bottom: 1rem;
355 padding-left: 0.5rem;
356}
357
358.component-list {
359 display: grid;
360 gap: 0.75rem;
361}
362
363.component-item {
364 background: #f8fafc;
365 border: 2px solid transparent;
366 border-radius: 0.75rem;
367 padding: 1rem;
368 cursor: grab;
369 transition: all 0.2s;
370 user-select: none;
371}
372
373.component-item:hover {
374 background: #f1f5f9;
375 border-color: #e2e8f0;
376 transform: translateY(-2px);
377 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
378}
379
380.component-item:active {
381 cursor: grabbing;
382}
383
384.component-item.dragging {
385 opacity: 0.5;
386 transform: rotate(5deg);
387}
388
389.component-preview {
390 margin-bottom: 0.5rem;
391 display: flex;
392 justify-content: center;
393 align-items: center;
394 height: 40px;
395}
396
397.component-name {
398 display: block;
399 text-align: center;
400 font-size: 0.875rem;
401 font-weight: 500;
402 color: #475569;
403}
404
405/* 头部样式 */
406.header {
407 background: white;
408 border-bottom: 1px solid #e2e8f0;
409 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
410}
411
412.header-content {
413 display: flex;
414 justify-content: space-between;
415 align-items: center;
416 max-width: 1400px;
417 margin: 0 auto;
418}
419
420.logo {
421 display: flex;
422 align-items: center;
423 gap: 0.5rem;
424 font-size: 1.5rem;
425 font-weight: 700;
426 color: #1e293b;
427}
428
429.logo-icon {
430 font-size: 2rem;
431}
432
433.header-actions {
434 display: flex;
435 gap: 1rem;
436}
437
438.canvas-placeholder {
439 position: absolute;
440 top: 50%;
441 left: 50%;
442 transform: translate(-50%, -50%);
443 text-align: center;
444 color: #9ca3af;
445
446}
447
448.placeholder-content {
449 width: 100%;
450 height: 100%;
451 display: flex;
452 justify-content: center;
453 align-items: center;
454 flex-direction: column;
455}
456
457.placeholder-icon {
458 font-size: 4rem;
459 margin-bottom: 1rem;
460}
461
462.placeholder-content h3 {
463 font-size: 1.5rem;
464 margin-bottom: 0.5rem;
465 color: #6b7280;
466}
467
468.placeholder-content p {
469 font-size: 1rem;
470 max-width: 300px;
471 color: #1a1a1a;
472}
473
474
475</style>
476