返回博客列表

代码展示

2026-01-29
6 min read
v-ui-pro

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>

返回博客列表
最后更新于 2026-01-29
想法或问题?在 GitHub Issue 下方参与讨论
去评论