项目概况

代码展示

2026-01-296 min read未分类
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} 28
main.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} 18
vue
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