package.json
{
"name": "v-ui-pro",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@arco-design/web-vue": "^2.57.0",
"@vueuse/core": "^13.6.0",
"animate.css": "^4.1.1",
"grid-layout-plus": "^1.1.0",
"less": "^4.4.0",
"lodash": "^4.17.21",
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vue-tsc": "^2.2.12"
}
}
main.ts
import {createApp} from 'vue'
import ArcoVue from '@arco-design/web-vue';
import ArcoVueIcon from "@arco-design/web-vue/es/icon";
import App from './App.vue';
import '@arco-design/web-vue/dist/arco.css';
import 'animate.css';
const app = createApp(App);
app.use(ArcoVue);
app.use(ArcoVueIcon)
app.mount('#app');
// 全局禁止 Vue 的 warn 打印
app.config.warnHandler = () => {
// 什么都不做,相当于吞掉 warning
}
vue
<template>
<div class="layout-demo">
<a-layout style="height: 100vh">
<a-layout-header style="height: 5vh;padding-right: 5vw">
<div
style="width: 100%;
height: 100%;gap:5px;display: flex;justify-content: end;
align-items: center;">
<div class="nav-item">
<a-switch v-model="draggable">
<template #checked>
拖动
</template>
<template #unchecked>
停止
</template>
</a-switch>
</div>
<div class="nav-item" style="color: #1a1a1a">
<a-button shape="circle" @click="handleDark">
<template #icon>
<icon-sun v-if="dark"/>
<icon-moon v-else/>
</template>
</a-button>
</div>
</div>
</a-layout-header>
<a-layout>
<a-layout-sider :resize-directions="['right']" style="height: 95vh;">
<div class="component-categories">
<div class="category" v-for="dir in Object.keys(componentGroups)" :key="dir">
<h3 class="category-title">{{ dir }}</h3>
<div class="component-list">
<div class="component-item" v-for="item in componentGroups[dir]" draggable="true"
@dragstart="dragStart(item)" @dragend="dragEnd(item)">
<div class="component-preview">
<span class="component-name">{{ item.name }}</span>
</div>
</div>
</div>
</div>
</div>
</a-layout-sider>
<a-layout-content ref="dropZoneRef">
<GridLayout v-if="layout.length>0" style="height: 100%;width: 100%" v-model:layout="layout"
:col-num="colNum"
:row-height="30"
:is-draggable="draggable"
:is-resizable="resizable"
vertical-compact
use-css-transforms
ref="gridLayout"
drag-allow-from=".vue-draggable-handle"
>
<template #item="{ item }">
<a-dropdown trigger="contextMenu" alignPoint :style="{display:'block'}">
<component :is="loadComponent((item as any).filePath)"/>
<template #content>
<a-doption @click="deleteAction(item)">
<template #icon>
<icon-delete/>
</template>
删除
</a-doption>
<a-doption>
<template #icon>
<icon-copy/>
</template>
复制代码
</a-doption>
</template>
</a-dropdown>
</template>
</GridLayout>
<div v-else class="placeholder-content">
<div class="placeholder-icon">?</div>
<h3>开始设计</h3>
<p>从左侧拖拽组件到这里开始创建您的界面</p>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</div>
</template>
<script setup lang="ts">
import type {GridItemProps} from 'grid-layout-plus';
import {GridLayout} from 'grid-layout-plus'
import {type Component, ref, defineAsyncComponent, watch} from "vue";
import {useDropZone, useStorage} from '@vueuse/core'
const dark = ref(false);
const handleDark = () => {
dark.value = !dark.value;
if (dark.value) {
// 设置为暗黑主题
document.body.setAttribute('arco-theme', 'dark')
} else {
// 恢复亮色主题
document.body.removeAttribute('arco-theme');
}
}
interface ComponentItem extends GridItemProps {
name?: string,
dirName?: string,
filePath?: string,
component?: any
}
type ComponentGroup = Record<string, Partial<ComponentItem>[]>;
// 分组结果
const componentGroups: ComponentGroup = {};
const currentDropComponentItem = ref<ComponentItem>();
// 缓存已创建的异步组件(按 filePath 缓存包装器)
const asyncComponentCache = new Map<string, ReturnType<typeof defineAsyncComponent>>()
// 缓存 import() 的 Promise(避免重复请求)
const importPromiseCache = new Map<string, Promise<any>>()
const loadComponent = (filePath: string) => {
// 1. 如果已经创建过这个异步组件,直接返回缓存的
if (asyncComponentCache.has(filePath)) {
return asyncComponentCache.get(filePath)!
}
// 2. 如果 import() 的 Promise 已存在,复用它(避免重复加载)
let importPromise = importPromiseCache.get(filePath)
if (!importPromise) {
importPromise = import(/* @vite-ignore */ filePath).catch(err => {
console.error(`Failed to load component: ${filePath}`, err)
// 清除失败的 promise,下次可重试
importPromiseCache.delete(filePath)
throw err
})
importPromiseCache.set(filePath, importPromise)
}
// 3. 创建异步组件包装器
const asyncComponent = defineAsyncComponent(() => {
return importPromise!.then(module => {
// 可以处理 module.default 或其他导出
return module.default || module
})
})
// 4. 缓存包装器,下次直接复用
asyncComponentCache.set(filePath, asyncComponent)
return asyncComponent
}
const modules = import.meta.glob('./components/*/*.vue', {eager: true});
// 明确告诉 TypeScript:每个模块都有一个 default 导出(Vue 组件)
const typedModules = modules as Record<string, { default: Component }>;
for (const filePath in typedModules) {
// ✅ 提取文件夹名(即 dirName)
const dirName = filePath.split('/').slice(-2)[0]; // 取倒数第二段
const name = filePath.split('/').pop()?.replace(/.w+$/, '') || ''; // 如 Button.vue → Button
const item: Partial<ComponentItem> = {
name,
dirName,
filePath,
};
if (!componentGroups[dirName]) {
componentGroups[dirName] = [];
}
componentGroups[dirName].push(item);
}
console.log(componentGroups)
const colNum = ref(12)
const layout = useStorage<ComponentItem[]>('layout', [])
const draggable = ref(true)
const resizable = ref(true)
// const itemBg = ref('#fff')
const itemBorder = ref('2px dashed #cbd5e1')
watch(() => draggable.value, nv => {
if (nv) {
itemBorder.value = '2px dashed #cbd5e1'
resizable.value = true;
} else {
itemBorder.value = ''
resizable.value = false;
}
})
const gridLayout = ref<InstanceType<typeof GridLayout>>()
const dropZoneRef = ref<HTMLDivElement>()
function onDrop() {
// called when files are dropped on zone
// ✅ 3. 生成唯一 ID
const id = `comp-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`
// ✅ 4. 添加到布局
layout.value.push({
x: 0,
y: 0,
w: 4, // 可配置
h: 4,
i: id,
...currentDropComponentItem.value
})
}
useDropZone(dropZoneRef, {
onDrop,
// specify the types of data to be received.
// control multi-file drop
multiple: false,
// whether to prevent default behavior for unhandled events
preventDefaultForUnhandled: false,
})
// const backgroundImage = ref('linear-gradient(to right, lightgrey 1px, transparent 1px), linear-gradient(to bottom, lightgrey 1px, transparent 1px)')
const dragStart = (item: ComponentItem | any) => {
currentDropComponentItem.value = item;
}
const dragEnd = (item: ComponentItem | any) => {
currentDropComponentItem.value = item;
}
const deleteAction = (item: ComponentItem) => {
let number = layout.value.findIndex(x => x.i === item.i);
layout.value.splice(number, 1);
}
</script>
<style lang="less" scoped>
.layout-demo {
height: 100vh;
}
.layout-demo :deep(.arco-layout-header),
.layout-demo :deep(.arco-layout-footer),
.layout-demo :deep(.arco-layout-sider-children),
.layout-demo :deep(.arco-layout-content) {
display: flex;
flex-direction: column;
justify-content: center;
color: var(--color-white);
font-size: 16px;
font-stretch: condensed;
text-align: center;
}
.layout-demo :deep(.arco-layout-header),
.layout-demo :deep(.arco-layout-footer) {
height: 64px;
//background-color: var(--color-primary-light-4);
}
.layout-demo :deep(.arco-layout-sider) {
width: 206px;
//background-color: var(--color-primary-light-3);
min-width: 150px;
max-width: 500px;
height: 200px;
}
.layout-demo :deep(.arco-layout-content) {
background-color: var(--color-neutral-3);
position: relative;
}
.vgl-layout {
background-color: #eee;
padding: 0;
}
//.vgl-layout::before {
// position: absolute;
// left: 0;
// top: 0;
// bottom: 0;
// right: 0;
// width: calc(100vw - 5px);
// height: calc(100vh - 5px);
// margin: 5px;
// content: '';
// //width: 100vw;
// //height: 100vw;
// background-image: linear-gradient(to right, lightgrey 1px, transparent 1px), linear-gradient(to bottom, lightgrey 1px, transparent 1px);
// background-repeat: repeat;
// background-size: calc(calc(100% - 5px) / 12) 40px;
//}
.vgl-layout::before {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
width: calc(100vw);
height: calc(100vh);
margin: 5px;
content: '';
//background-image: linear-gradient(to right, lightgrey 1px, transparent 1px),
//linear-gradient(to bottom, lightgrey 1px, transparent 1px);
//background-repeat: repeat;
//background-size: calc(calc(100vw) / 12) 40px;
}
:deep(.vgl-item) {
overflow: hidden;
}
:deep(.vgl-item:not(.vgl-item--placeholder)) {
//background-color: v-bind(itemBg);
border: v-bind(itemBorder);
}
:deep(.vgl-item:not(.vgl-item--placeholder):hover) {
border-color: #94a3b8;
background: rgba(241, 245, 249, 0.5);
}
/* 组件分类 */
.component-categories {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.category {
margin-bottom: 2rem;
}
.category-title {
font-size: 0.875rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
padding-left: 0.5rem;
}
.component-list {
display: grid;
gap: 0.75rem;
}
.component-item {
background: #f8fafc;
border: 2px solid transparent;
border-radius: 0.75rem;
padding: 1rem;
cursor: grab;
transition: all 0.2s;
user-select: none;
}
.component-item:hover {
background: #f1f5f9;
border-color: #e2e8f0;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.component-item:active {
cursor: grabbing;
}
.component-item.dragging {
opacity: 0.5;
transform: rotate(5deg);
}
.component-preview {
margin-bottom: 0.5rem;
display: flex;
justify-content: center;
align-items: center;
height: 40px;
}
.component-name {
display: block;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
color: #475569;
}
/* 头部样式 */
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
}
.logo-icon {
font-size: 2rem;
}
.header-actions {
display: flex;
gap: 1rem;
}
.canvas-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #9ca3af;
}
.placeholder-content {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.placeholder-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.placeholder-content h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #6b7280;
}
.placeholder-content p {
font-size: 1rem;
max-width: 300px;
color: #1a1a1a;
}
</style>
想法或问题?在 GitHub Issue 下方参与讨论
去评论