第275集:Vue 3低代码平台可视化编辑器实现

一、可视化编辑器概述

可视化编辑器是低代码平台的核心交互界面,它允许用户通过拖拽、配置等可视化操作来构建应用界面,无需编写大量代码。一个功能完整的可视化编辑器通常包括组件面板、画布区域、属性配置面板和工具栏等核心部分。

1.1 核心功能

  • 组件拖拽:从组件面板拖拽组件到画布
  • 画布操作:缩放、平移、选择组件
  • 组件配置:通过属性面板配置组件属性
  • 组件嵌套:支持组件的层级嵌套
  • 撤销/重做:支持操作历史记录
  • 保存/导出:保存当前设计或导出为代码
  • 预览功能:实时预览设计效果
  • 响应式设计:支持不同屏幕尺寸的设计

1.2 架构设计

可视化编辑器通常采用分层架构设计:

  • 表现层:负责用户界面渲染和交互
  • 核心引擎层:处理拖拽逻辑、组件渲染、属性更新等核心功能
  • 数据层:管理组件树数据、操作历史、配置信息等
  • 导出层:负责将设计导出为代码或其他格式

二、核心实现

2.1 项目初始化

使用Vite创建一个Vue 3项目:

npm create vite@latest visual-editor -- --template vue-ts
cd visual-editor
npm install
# 安装必要的依赖
npm install vue-draggable-next @vueuse/core

2.2 数据模型设计

首先,我们需要设计可视化编辑器的数据模型,包括组件树、组件配置、操作历史等:

// 组件配置接口
export interface ComponentConfig {
  id: string;
  type: string;
  label: string;
  props: Record<string, any>;
  children?: ComponentConfig[];
  parentId?: string;
  position?: {
    x: number;
    y: number;
  };
  size?: {
    width: number;
    height: number;
  };
}

// 操作历史项接口
export interface HistoryItem {
  id: string;
  type: 'add' | 'delete' | 'update' | 'move';
  data: any;
  timestamp: number;
}

// 编辑器状态接口
export interface EditorState {
  // 组件树
  componentTree: ComponentConfig;
  // 选中的组件ID
  selectedComponentId: string | null;
  // 操作历史
  history: HistoryItem[];
  // 历史记录指针
  historyIndex: number;
  // 画布缩放比例
  zoom: number;
  // 画布偏移量
  canvasOffset: { x: number; y: number };
  // 是否处于拖拽状态
  isDragging: boolean;
}

// 组件库接口
export interface ComponentLibrary {
  id: string;
  name: string;
  category: string;
  icon?: string;
  description?: string;
  defaultProps?: Record<string, any>;
  // 组件渲染函数或组件引用
  component: any;
  // 可配置的属性列表
  configurableProps: {
    name: string;
    label: string;
    type: 'text' | 'number' | 'boolean' | 'select' | 'color' | 'object';
    options?: { label: string; value: any }[];
    defaultValue?: any;
    required?: boolean;
  }[];
}

2.3 编辑器主组件

实现可视化编辑器的主组件,整合组件面板、画布和属性面板:

<template>
  <div class="visual-editor">
    <!-- 顶部工具栏 -->
    <div class="editor-toolbar">
      <div class="toolbar-left">
        <h1>Vue 3 可视化编辑器</h1>
      </div>
      <div class="toolbar-right">
        <button @click="handleUndo" :disabled="!canUndo">撤销</button>
        <button @click="handleRedo" :disabled="!canRedo">重做</button>
        <button @click="handleSave">保存</button>
        <button @click="handleExport">导出</button>
        <button @click="togglePreview">
          {{ isPreviewMode ? '退出预览' : '预览' }}
        </button>
      </div>
    </div>
    
    <!-- 编辑器主体 -->
    <div class="editor-main" :class="{ 'preview-mode': isPreviewMode }">
      <!-- 左侧组件面板 -->
      <div class="component-panel" v-if="!isPreviewMode">
        <div class="panel-header">
          <h3>组件库</h3>
        </div>
        <div class="panel-content">
          <div 
            v-for="category in componentCategories" 
            :key="category"
            class="component-category"
          >
            <h4>{{ category }}</h4>
            <div 
              v-for="component in getComponentsByCategory(category)" 
              :key="component.id"
              class="component-item"
              draggable="true"
              @dragstart="handleDragStart($event, component)"
            >
              <div class="component-icon">{{ component.icon || '📦' }}</div>
              <div class="component-info">
                <div class="component-name">{{ component.name }}</div>
                <div class="component-description">{{ component.description }}</div>
              </div>
            </div>
          </div>
        </div>
      </div>
      
      <!-- 中间画布区域 -->
      <div class="canvas-area">
        <div 
          class="canvas-container"
          ref="canvasContainer"
          @dragover.prevent
          @drop="handleDrop"
          @mousedown="handleCanvasMouseDown"
        >
          <div 
            class="canvas"
            :style="{
              transform: `scale(${editorState.zoom}) translate(${editorState.canvasOffset.x}px, ${editorState.canvasOffset.y}px)`,
              transformOrigin: '0 0'
            }"
          >
            <!-- 画布背景网格 -->
            <div class="canvas-grid"></div>
            
            <!-- 渲染组件树 -->
            <component-node
              v-if="editorState.componentTree"
              :component="editorState.componentTree"
              :selected-id="editorState.selectedComponentId"
              :is-preview="isPreviewMode"
              @select="handleComponentSelect"
              @update="handleComponentUpdate"
              @delete="handleComponentDelete"
              @move="handleComponentMove"
            />
          </div>
        </div>
      </div>
      
      <!-- 右侧属性面板 -->
      <div class="property-panel" v-if="!isPreviewMode && selectedComponent">
        <div class="panel-header">
          <h3>属性配置</h3>
          <button class="delete-btn" @click="handleComponentDelete(selectedComponent.id)">删除</button>
        </div>
        <div class="panel-content">
          <div class="property-group">
            <h4>基本信息</h4>
            <div class="property-item">
              <label>组件类型</label>
              <input type="text" v-model="selectedComponent.type" disabled />
            </div>
            <div class="property-item">
              <label>组件ID</label>
              <input type="text" v-model="selectedComponent.id" disabled />
            </div>
          </div>
          
          <!-- 动态渲染可配置属性 -->
          <div class="property-group">
            <h4>组件属性</h4>
            <div 
              v-for="propConfig in getComponentConfig(selectedComponent.type)?.configurableProps" 
              :key="propConfig.name"
              class="property-item"
            >
              <label>{{ propConfig.label }}</label>
              
              <!-- 文本输入 -->
              <input 
                v-if="propConfig.type === 'text'"
                type="text" 
                v-model="selectedComponent.props[propConfig.name]"
                @input="handlePropertyChange"
              />
              
              <!-- 数字输入 -->
              <input 
                v-else-if="propConfig.type === 'number'"
                type="number" 
                v-model.number="selectedComponent.props[propConfig.name]"
                @input="handlePropertyChange"
              />
              
              <!-- 布尔值开关 -->
              <input 
                v-else-if="propConfig.type === 'boolean'"
                type="checkbox" 
                v-model="selectedComponent.props[propConfig.name]"
                @change="handlePropertyChange"
              />
              
              <!-- 下拉选择 -->
              <select 
                v-else-if="propConfig.type === 'select'"
                v-model="selectedComponent.props[propConfig.name]"
                @change="handlePropertyChange"
              >
                <option 
                  v-for="option in propConfig.options" 
                  :key="option.value"
                  :value="option.value"
                >
                  {{ option.label }}
                </option>
              </select>
              
              <!-- 颜色选择 -->
              <input 
                v-else-if="propConfig.type === 'color'"
                type="color" 
                v-model="selectedComponent.props[propConfig.name]"
                @input="handlePropertyChange"
              />
              
              <!-- 对象输入(JSON格式) -->
              <textarea 
                v-else-if="propConfig.type === 'object'"
                v-model="objectProps[propConfig.name]"
                @input="handleObjectPropertyChange(propConfig.name)"
                rows="4"
              ></textarea>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue';
import type { ComponentConfig, EditorState, ComponentLibrary, HistoryItem } from './types';
import ComponentNode from './components/ComponentNode.vue';

// 组件库定义
const componentLibrary: ComponentLibrary[] = [
  {
    id: 'button',
    name: '按钮',
    category: '基础组件',
    icon: '🔘',
description: '基础按钮组件',
    defaultProps: {
      type: 'primary',
      text: '按钮',
      size: 'medium'
    },
    component: 'Button',
    configurableProps: [
      {
        name: 'type',
        label: '按钮类型',
        type: 'select',
        options: [
          { label: '主要', value: 'primary' },
          { label: '次要', value: 'secondary' },
          { label: '成功', value: 'success' },
          { label: '危险', value: 'danger' }
        ],
        defaultValue: 'primary'
      },
      {
        name: 'text',
        label: '按钮文本',
        type: 'text',
        defaultValue: '按钮',
        required: true
      },
      {
        name: 'size',
        label: '按钮尺寸',
        type: 'select',
        options: [
          { label: '小', value: 'small' },
          { label: '中', value: 'medium' },
          { label: '大', value: 'large' }
        ],
        defaultValue: 'medium'
      }
    ]
  },
  {
    id: 'input',
    name: '输入框',
    category: '基础组件',
    icon: '📝',
description: '文本输入框',
    defaultProps: {
      placeholder: '请输入内容',
      value: '',
      disabled: false
    },
    component: 'Input',
    configurableProps: [
      {
        name: 'placeholder',
        label: '占位文本',
        type: 'text',
        defaultValue: '请输入内容'
      },
      {
        name: 'value',
        label: '默认值',
        type: 'text',
        defaultValue: ''
      },
      {
        name: 'disabled',
        label: '是否禁用',
        type: 'boolean',
        defaultValue: false
      }
    ]
  },
  {
    id: 'container',
    name: '容器',
    category: '布局组件',
    icon: '📦',
description: '容器组件,用于布局',
    defaultProps: {
      class: 'container',
      style: { padding: '20px', backgroundColor: '#f5f5f5' }
    },
    component: 'div',
    configurableProps: [
      {
        name: 'class',
        label: 'CSS类名',
        type: 'text',
        defaultValue: 'container'
      },
      {
        name: 'style',
        label: '内联样式',
        type: 'object',
        defaultValue: { padding: '20px', backgroundColor: '#f5f5f5' }
      }
    ]
  }
];

// 获取组件分类
const componentCategories = computed(() => {
  const categories = new Set<string>();
  componentLibrary.forEach(comp => categories.add(comp.category));
  return Array.from(categories);
});

// 根据分类获取组件
const getComponentsByCategory = (category: string) => {
  return componentLibrary.filter(comp => comp.category === category);
};

// 根据组件类型获取组件配置
const getComponentConfig = (type: string) => {
  return componentLibrary.find(comp => comp.id === type);
};

// 初始编辑器状态
const initialState: EditorState = {
  componentTree: {
    id: 'root',
    type: 'container',
    label: '根容器',
    props: {
      class: 'root-container',
      style: { width: '100%', height: '100%', minHeight: '600px' }
    },
    children: []
  },
  selectedComponentId: null,
  history: [],
  historyIndex: -1,
  zoom: 1,
  canvasOffset: { x: 0, y: 0 },
  isDragging: false
};

// 编辑器状态
const editorState = reactive<EditorState>({ ...initialState });

// 预览模式
const isPreviewMode = ref(false);

// 画布容器引用
const canvasContainer = ref<HTMLElement>();

// 当前拖拽的组件
const draggingComponent = ref<ComponentLibrary | null>(null);

// 对象类型属性的JSON表示
const objectProps = reactive<Record<string, string>>({});

// 选中的组件
const selectedComponent = computed(() => {
  if (!editorState.selectedComponentId) return null;
  return findComponentById(editorState.componentTree, editorState.selectedComponentId);
});

// 监听选中组件变化,更新对象属性的JSON表示
watch(selectedComponent, (newComponent) => {
  if (newComponent) {
    // 清空之前的对象属性
    Object.keys(objectProps).forEach(key => delete objectProps[key]);
    
    // 更新对象属性的JSON表示
    const compConfig = getComponentConfig(newComponent.type);
    if (compConfig) {
      compConfig.configurableProps.forEach(prop => {
        if (prop.type === 'object' && newComponent.props[prop.name]) {
          objectProps[prop.name] = JSON.stringify(newComponent.props[prop.name], null, 2);
        }
      });
    }
  }
}, { immediate: true, deep: true });

// 能否撤销
const canUndo = computed(() => editorState.historyIndex > 0);

// 能否重做
const canRedo = computed(() => editorState.historyIndex < editorState.history.length - 1);

// 根据ID查找组件
function findComponentById(component: ComponentConfig, id: string): ComponentConfig | null {
  if (component.id === id) return component;
  if (component.children) {
    for (const child of component.children) {
      const found = findComponentById(child, id);
      if (found) return found;
    }
  }
  return null;
}

// 生成唯一ID
function generateId(): string {
  return `comp_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
}

// 处理拖拽开始
function handleDragStart(event: DragEvent, component: ComponentLibrary) {
  draggingComponent.value = component;
  if (event.dataTransfer) {
    event.dataTransfer.effectAllowed = 'copy';
  }
}

// 处理放置组件
function handleDrop(event: DragEvent) {
  if (!draggingComponent.value) return;
  
  event.preventDefault();
  
  // 获取放置位置
  const rect = canvasContainer.value?.getBoundingClientRect();
  if (!rect) return;
  
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;
  
  // 创建新组件
  const newComponent: ComponentConfig = {
    id: generateId(),
    type: draggingComponent.value.id,
    label: draggingComponent.value.name,
    props: { ...draggingComponent.value.defaultProps },
    position: { x, y },
    parentId: editorState.componentTree.id
  };
  
  // 添加到根容器
  if (!editorState.componentTree.children) {
    editorState.componentTree.children = [];
  }
  
  // 记录历史
  addHistory({
    id: generateId(),
    type: 'add',
    data: { component: newComponent, parentId: editorState.componentTree.id },
    timestamp: Date.now()
  });
  
  // 添加组件
  editorState.componentTree.children.push(newComponent);
  
  // 选中新添加的组件
  editorState.selectedComponentId = newComponent.id;
  
  draggingComponent.value = null;
}

// 处理组件选择
function handleComponentSelect(componentId: string) {
  editorState.selectedComponentId = componentId;
}

// 处理组件更新
function handleComponentUpdate(component: ComponentConfig) {
  // 找到并更新组件
  const updateComponent = (tree: ComponentConfig) => {
    if (tree.id === component.id) {
      Object.assign(tree, component);
      return true;
    }
    if (tree.children) {
      for (let i = 0; i < tree.children.length; i++) {
        if (updateComponent(tree.children[i])) {
          return true;
        }
      }
    }
    return false;
  };
  
  updateComponent(editorState.componentTree);
}

// 处理组件删除
function handleComponentDelete(componentId: string) {
  if (componentId === 'root') return; // 不能删除根组件
  
  // 找到要删除的组件及其父组件
  let parent: ComponentConfig | null = null;
  let componentToDelete: ComponentConfig | null = null;
  let index = -1;
  
  const findComponentAndParent = (tree: ComponentConfig, parentComp: ComponentConfig | null = null) => {
    if (tree.id === componentId) {
      componentToDelete = tree;
      parent = parentComp;
      return true;
    }
    if (tree.children) {
      for (let i = 0; i < tree.children.length; i++) {
        if (findComponentAndParent(tree.children[i], tree)) {
          index = i;
          return true;
        }
      }
    }
    return false;
  };
  
  findComponentAndParent(editorState.componentTree);
  
  if (parent && componentToDelete && index >= 0 && parent.children) {
    // 记录历史
    addHistory({
      id: generateId(),
      type: 'delete',
      data: { component: componentToDelete, parentId: parent.id, index },
      timestamp: Date.now()
    });
    
    // 删除组件
    parent.children.splice(index, 1);
    
    // 取消选择
    if (editorState.selectedComponentId === componentId) {
      editorState.selectedComponentId = null;
    }
  }
}

// 处理组件移动
function handleComponentMove(componentId: string, newPosition: { x: number; y: number }) {
  const component = findComponentById(editorState.componentTree, componentId);
  if (component) {
    // 记录历史
    addHistory({
      id: generateId(),
      type: 'move',
      data: {
        componentId,
        oldPosition: { ...component.position },
        newPosition
      },
      timestamp: Date.now()
    });
    
    // 更新位置
    component.position = newPosition;
  }
}

// 处理属性变化
function handlePropertyChange() {
  if (selectedComponent.value) {
    // 记录历史
    addHistory({
      id: generateId(),
      type: 'update',
      data: {
        componentId: selectedComponent.value.id,
        oldProps: { ...selectedComponent.value.props },
        newProps: { ...selectedComponent.value.props }
      },
      timestamp: Date.now()
    });
  }
}

// 处理对象属性变化
function handleObjectPropertyChange(propName: string) {
  if (selectedComponent.value) {
    try {
      const parsedValue = JSON.parse(objectProps[propName]);
      selectedComponent.value.props[propName] = parsedValue;
      handlePropertyChange();
    } catch (e) {
      console.error('Invalid JSON:', e);
    }
  }
}

// 处理画布鼠标按下
function handleCanvasMouseDown(event: MouseEvent) {
  // 点击画布空白处,取消选择
  if ((event.target as HTMLElement).classList.contains('canvas') || 
      (event.target as HTMLElement).classList.contains('canvas-grid')) {
    editorState.selectedComponentId = null;
  }
}

// 添加历史记录
function addHistory(item: HistoryItem) {
  // 如果当前不在历史记录末尾,删除后面的历史
  if (editorState.historyIndex < editorState.history.length - 1) {
    editorState.history = editorState.history.slice(0, editorState.historyIndex + 1);
  }
  
  // 添加新历史
  editorState.history.push(item);
  editorState.historyIndex = editorState.history.length - 1;
  
  // 限制历史记录数量
  if (editorState.history.length > 50) {
    editorState.history.shift();
    editorState.historyIndex--;
  }
}

// 撤销操作
function handleUndo() {
  if (!canUndo.value) return;
  
  const historyItem = editorState.history[editorState.historyIndex];
  if (!historyItem) return;
  
  // 根据操作类型执行撤销
  switch (historyItem.type) {
    case 'add':
      // 撤销添加:删除组件
      handleComponentDelete(historyItem.data.component.id);
      break;
    case 'delete':
      // 撤销删除:重新添加组件
      const { component, parentId, index } = historyItem.data;
      const parent = findComponentById(editorState.componentTree, parentId);
      if (parent && parent.children) {
        parent.children.splice(index, 0, component);
      }
      break;
    case 'update':
      // 撤销更新:恢复旧属性
      const { componentId, oldProps } = historyItem.data;
      const comp = findComponentById(editorState.componentTree, componentId);
      if (comp) {
        comp.props = oldProps;
      }
      break;
    case 'move':
      // 撤销移动:恢复旧位置
      const { componentId: moveCompId, oldPosition } = historyItem.data;
      const moveComp = findComponentById(editorState.componentTree, moveCompId);
      if (moveComp) {
        moveComp.position = oldPosition;
      }
      break;
  }
  
  editorState.historyIndex--;
}

// 重做操作
function handleRedo() {
  if (!canRedo.value) return;
  
  editorState.historyIndex++;
  const historyItem = editorState.history[editorState.historyIndex];
  if (!historyItem) return;
  
  // 根据操作类型执行重做
  switch (historyItem.type) {
    case 'add':
      // 重做添加:重新添加组件
      const { component: addComp, parentId: addParentId } = historyItem.data;
      const addParent = findComponentById(editorState.componentTree, addParentId);
      if (addParent && addParent.children) {
        addParent.children.push(addComp);
      }
      break;
    case 'delete':
      // 重做删除:删除组件
      handleComponentDelete(historyItem.data.component.id);
      break;
    case 'update':
      // 重做更新:应用新属性
      const { componentId: redoCompId, newProps } = historyItem.data;
      const redoComp = findComponentById(editorState.componentTree, redoCompId);
      if (redoComp) {
        redoComp.props = newProps;
      }
      break;
    case 'move':
      // 重做移动:应用新位置
      const { componentId: redoMoveId, newPosition: redoNewPos } = historyItem.data;
      const redoMoveComp = findComponentById(editorState.componentTree, redoMoveId);
      if (redoMoveComp) {
        redoMoveComp.position = redoNewPos;
      }
      break;
  }
}

// 保存设计
function handleSave() {
  const designData = {
    componentTree: editorState.componentTree,
    version: '1.0.0',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  };
  
  // 保存到本地存储(实际项目中可能保存到服务器)
  localStorage.setItem('visual-editor-design', JSON.stringify(designData));
  alert('保存成功!');
}

// 导出设计
function handleExport() {
  const designData = {
    componentTree: editorState.componentTree,
    version: '1.0.0',
    createdAt: new Date().toISOString()
  };
  
  // 创建下载链接
  const dataStr = JSON.stringify(designData, null, 2);
  const dataBlob = new Blob([dataStr], { type: 'application/json' });
  const url = URL.createObjectURL(dataBlob);
  const link = document.createElement('a');
  link.href = url;
  link.download = `design-${Date.now()}.json`;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

// 切换预览模式
function togglePreview() {
  isPreviewMode.value = !isPreviewMode.value;
}

onMounted(() => {
  // 从本地存储加载设计(如果存在)
  const savedDesign = localStorage.getItem('visual-editor-design');
  if (savedDesign) {
    try {
      const designData = JSON.parse(savedDesign);
      editorState.componentTree = designData.componentTree;
    } catch (e) {
      console.error('Failed to load saved design:', e);
    }
  }
});
</script>

<style scoped>
.visual-editor {
  display: flex;
  flex-direction: column;
  height: 100vh;
  width: 100vw;
  overflow: hidden;
  font-family: Arial, sans-serif;
}

/* 工具栏样式 */
.editor-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 20px;
  background-color: #1976d2;
  color: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.toolbar-left h1 {
  margin: 0;
  font-size: 18px;
  font-weight: 600;
}

.toolbar-right {
  display: flex;
  gap: 10px;
}

.toolbar-right button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background-color: rgba(255, 255, 255, 0.2);
  color: white;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s ease;
}

.toolbar-right button:hover:not(:disabled) {
  background-color: rgba(255, 255, 255, 0.3);
}

.toolbar-right button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* 编辑器主体样式 */
.editor-main {
  display: flex;
  flex: 1;
  overflow: hidden;
  background-color: #f5f5f5;
}

/* 预览模式 */
.editor-main.preview-mode {
  background-color: white;
}

/* 组件面板样式 */
.component-panel {
  width: 280px;
  background-color: white;
  border-right: 1px solid #e0e0e0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.panel-header {
  padding: 16px;
  border-bottom: 1px solid #e0e0e0;
  background-color: #fafafa;
}

.panel-header h3 {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
}

.panel-content {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}

.component-category {
  margin-bottom: 20px;
}

.component-category h4 {
  margin: 0 0 12px 0;
  font-size: 14px;
  font-weight: 600;
  color: #666;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.component-item {
  display: flex;
  align-items: center;
  padding: 12px;
  background-color: white;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  margin-bottom: 8px;
  cursor: grab;
  transition: all 0.3s ease;
}

.component-item:hover {
  border-color: #1976d2;
  box-shadow: 0 2px 8px rgba(25, 118, 210, 0.15);
  transform: translateY(-1px);
}

.component-icon {
  font-size: 24px;
  margin-right: 12px;
}

.component-info {
  flex: 1;
}

.component-name {
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 4px;
}

.component-description {
  font-size: 12px;
  color: #666;
  line-height: 1.3;
}

/* 画布区域样式 */
.canvas-area {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: auto;
  padding: 20px;
  background-color: #f0f2f5;
}

.canvas-container {
  position: relative;
  background-color: white;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  border-radius: 8px;
  overflow: hidden;
  cursor: grab;
}

.canvas-container:active {
  cursor: grabbing;
}

.canvas {
  position: relative;
  width: 1200px;
  height: 800px;
  background-color: white;
  transition: transform 0.2s ease;
}

.canvas-grid {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-image: 
    linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
  background-size: 20px 20px;
  pointer-events: none;
}

/* 属性面板样式 */
.property-panel {
  width: 320px;
  background-color: white;
  border-left: 1px solid #e0e0e0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.property-panel .panel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.delete-btn {
  padding: 6px 12px;
  background-color: #f44336;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 12px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.delete-btn:hover {
  background-color: #d32f2f;
}

.property-group {
  margin-bottom: 20px;
}

.property-group h4 {
  margin: 0 0 12px 0;
  font-size: 14px;
  font-weight: 600;
  color: #666;
}

.property-item {
  margin-bottom: 12px;
}

.property-item label {
  display: block;
  font-size: 12px;
  font-weight: 500;
  color: #666;
  margin-bottom: 4px;
}

.property-item input[type="text"],
.property-item input[type="number"],
.property-item select,
.property-item textarea {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  font-size: 14px;
  transition: all 0.3s ease;
}

.property-item input[type="text"]:focus,
.property-item input[type="number"]:focus,
.property-item select:focus,
.property-item textarea:focus {
  outline: none;
  border-color: #1976d2;
  box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}

.property-item input[type="checkbox"] {
  margin-top: 4px;
  cursor: pointer;
}

.property-item input[type="color"] {
  width: 100%;
  height: 36px;
  padding: 2px;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  cursor: pointer;
}
</style>

2.4 组件节点组件

实现组件节点组件,用于渲染和管理单个组件:

<template>
  <div 
    class="component-node"
    :class="{
      'selected': isSelected,
      'draggable': !isPreview
    }"
    :style="nodeStyle"
    @click.stop="handleSelect"
    @mousedown.stop="handleMouseDown"
    draggable="!isPreview"
    @dragstart="handleDragStart"
  >
    <!-- 组件选中状态指示器 -->
    <div v-if="!isPreview && isSelected" class="selection-indicator">
      <div class="resize-handle top-left"></div>
      <div class="resize-handle top-right"></div>
      <div class="resize-handle bottom-left"></div>
      <div class="resize-handle bottom-right"></div>
    </div>
    
    <!-- 组件渲染 -->
    <component 
      :is="componentType"
      v-bind="component.props"
    >
      <!-- 渲染子组件 -->
      <component-node
        v-for="child in component.children"
        :key="child.id"
        :component="child"
        :selected-id="selectedId"
        :is-preview="isPreview"
        @select="$emit('select', $event)"
        @update="$emit('update', $event)"
        @delete="$emit('delete', $event)"
        @move="$emit('move', $event)"
      />
    </component>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import type { ComponentConfig } from './types';

const props = defineProps<{
  component: ComponentConfig;
  selectedId: string | null;
  isPreview: boolean;
}>();

const emit = defineEmits<{
  (e: 'select', componentId: string): void;
  (e: 'update', component: ComponentConfig): void;
  (e: 'delete', componentId: string): void;
  (e: 'move', componentId: string, newPosition: { x: number; y: number }): void;
}>();

// 是否选中
const isSelected = computed(() => props.component.id === props.selectedId);

// 组件类型
const componentType = computed(() => {
  // 对于HTML原生元素,直接返回标签名
  if (['div', 'span', 'h1', 'h2', 'h3', 'p', 'input', 'button'].includes(props.component.type)) {
    return props.component.type;
  }
  // 否则返回组件名称(需要在父组件中注册)
  return props.component.type;
});

// 节点样式
const nodeStyle = computed(() => {
  const style: Record<string, any> = {
    position: 'absolute',
    minWidth: '100px',
    minHeight: '40px',
    boxSizing: 'border-box'
  };
  
  // 如果有位置信息,应用定位
  if (props.component.position) {
    style.left = `${props.component.position.x}px`;
    style.top = `${props.component.position.y}px`;
  }
  
  // 如果有尺寸信息,应用尺寸
  if (props.component.size) {
    style.width = `${props.component.size.width}px`;
    style.height = `${props.component.size.height}px`;
  }
  
  // 应用组件自身的样式
  if (props.component.props.style) {
    Object.assign(style, props.component.props.style);
  }
  
  return style;
});

// 拖拽状态
const isDragging = ref(false);
const dragStartPos = ref({ x: 0, y: 0 });
const componentStartPos = ref({ x: 0, y: 0 });

// 处理选择
function handleSelect() {
  emit('select', props.component.id);
}

// 处理鼠标按下
function handleMouseDown(event: MouseEvent) {
  if (props.isPreview) return;
  
  // 只处理左键
  if (event.button !== 0) return;
  
  // 如果不是选中状态,先选中
  if (!isSelected.value) {
    emit('select', props.component.id);
  }
  
  // 记录初始位置
  isDragging.value = true;
  dragStartPos.value = { x: event.clientX, y: event.clientY };
  componentStartPos.value = {
    x: props.component.position?.x || 0,
    y: props.component.position?.y || 0
  };
  
  // 添加全局事件监听
  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
}

// 处理鼠标移动
function handleMouseMove(event: MouseEvent) {
  if (!isDragging.value) return;
  
  // 计算移动距离
  const deltaX = event.clientX - dragStartPos.value.x;
  const deltaY = event.clientY - dragStartPos.value.y;
  
  // 计算新位置
  const newPosition = {
    x: componentStartPos.value.x + deltaX,
    y: componentStartPos.value.y + deltaY
  };
  
  // 触发移动事件
  emit('move', props.component.id, newPosition);
}

// 处理鼠标释放
function handleMouseUp() {
  isDragging.value = false;
  
  // 移除全局事件监听
  document.removeEventListener('mousemove', handleMouseMove);
  document.removeEventListener('mouseup', handleMouseUp);
}

// 处理拖拽开始
function handleDragStart(event: DragEvent) {
  if (event.dataTransfer) {
    event.dataTransfer.effectAllowed = 'move';
    event.dataTransfer.setData('text/plain', props.component.id);
  }
}

onUnmounted(() => {
  // 确保移除事件监听
  document.removeEventListener('mousemove', handleMouseMove);
  document.removeEventListener('mouseup', handleMouseUp);
});
</script>

<style scoped>
.component-node {
  position: absolute;
  cursor: pointer;
  transition: all 0.2s ease;
  user-select: none;
}

.component-node.selected {
  outline: 2px solid #1976d2;
  box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2);
  z-index: 10;
}

.component-node.draggable:hover {
  outline: 1px dashed #1976d2;
}

/* 选择指示器 */
.selection-indicator {
  position: absolute;
  top: -8px;
  left: -8px;
  right: -8px;
  bottom: -8px;
  pointer-events: none;
  z-index: 20;
}

/* 调整大小手柄 */
.resize-handle {
  position: absolute;
  width: 16px;
  height: 16px;
  background-color: #1976d2;
  border: 2px solid white;
  border-radius: 50%;
  pointer-events: all;
  cursor: nwse-resize;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.resize-handle.top-left {
  top: 0;
  left: 0;
}

.resize-handle.top-right {
  top: 0;
  right: 0;
  cursor: nesw-resize;
}

.resize-handle.bottom-left {
  bottom: 0;
  left: 0;
  cursor: nesw-resize;
}

.resize-handle.bottom-right {
  bottom: 0;
  right: 0;
}
</style>

三、使用示例

3.1 基本使用

  1. 启动项目

    npm run dev
  2. 使用编辑器

    • 从左侧组件面板拖拽组件到中间画布
    • 点击选中组件,在右侧属性面板中配置组件属性
    • 使用顶部工具栏的撤销/重做、保存、导出、预览等功能
  3. 保存和导出

    • 点击"保存"按钮,设计会保存到浏览器本地存储
    • 点击"导出"按钮,可以将设计导出为JSON文件

3.2 扩展组件库

要扩展组件库,只需在componentLibrary数组中添加新的组件配置:

// 添加新组件
const componentLibrary: ComponentLibrary[] = [
  // 现有组件...
  {
    id: 'card',
    name: '卡片',
    category: '布局组件',
    icon: '🃏',
description: '卡片组件,用于展示内容',
    defaultProps: {
      title: '卡片标题',
      content: '卡片内容',
      footer: '卡片底部'
    },
    component: 'Card',
    configurableProps: [
      {
        name: 'title',
        label: '卡片标题',
        type: 'text',
        defaultValue: '卡片标题'
      },
      {
        name: 'content',
        label: '卡片内容',
        type: 'text',
        defaultValue: '卡片内容'
      },
      {
        name: 'footer',
        label: '卡片底部',
        type: 'text',
        defaultValue: '卡片底部'
      }
    ]
  }
];

四、性能优化

4.1 虚拟渲染

对于大型组件树,可以实现虚拟渲染,只渲染可见区域的组件:

// 虚拟渲染逻辑
const visibleComponents = computed(() => {
  // 根据画布可见区域计算可见的组件
  // 只返回可见区域内的组件
  return props.components.filter(component => {
    // 计算组件是否在可见区域内
    // ...
    return isVisible;
  });
});

4.2 防抖更新

对于频繁的属性更新,可以使用防抖机制减少渲染次数:

import { debounce } from 'lodash-es';

// 防抖处理属性更新
const debouncedPropertyChange = debounce(() => {
  // 处理属性更新
}, 300);

function handlePropertyChange() {
  debouncedPropertyChange();
}

4.3 组件缓存

对于不常变化的组件,可以进行缓存,避免重复渲染:

<template>
  <keep-alive>
    <component 
      :is="componentType"
      v-bind="component.props"
    >
      <!-- 子组件 -->
    </component>
  </keep-alive>
</template>

五、最佳实践

5.1 组件设计原则

  • 原子化设计:将组件拆分为原子组件、分子组件、组织组件和模板
  • 高内聚低耦合:组件内部功能内聚,组件之间耦合度低
  • 可配置性:组件应提供丰富的可配置属性
  • 响应式设计:组件应支持不同屏幕尺寸

5.2 编辑器设计原则

  • 直观易用:界面设计简洁直观,操作流程符合用户习惯
  • 实时反馈:用户操作应得到实时反馈
  • 可扩展性:支持自定义组件和插件
  • 性能优化:确保编辑器在大型项目中仍能流畅运行
  • 错误处理:提供友好的错误提示和容错机制

5.3 数据管理

  • 单一数据源:所有组件状态来自单一数据源
  • 不可变数据:使用不可变数据模式,便于撤销/重做
  • 状态管理:对于复杂应用,考虑使用Pinia或Vuex进行状态管理

六、总结

可视化编辑器是低代码平台的核心组件,它允许用户通过拖拽、配置等可视化操作来构建应用界面。在本集中,我们学习了:

  1. 可视化编辑器的核心功能和架构设计
  2. 使用Vue 3实现可视化编辑器的主界面
  3. 实现组件拖拽、选择、配置等核心功能
  4. 实现操作历史记录(撤销/重做)
  5. 实现保存、导出、预览等功能
  6. 性能优化策略和最佳实践

通过合理设计和实现可视化编辑器,我们可以构建出高效、易用的低代码平台,极大提高应用开发的效率和质量。

在下一集中,我们将学习如何实现低代码平台的数据源管理功能,进一步完善低代码开发环境。

« 上一篇 Vue 3动态组件渲染引擎实现 下一篇 » Vue 3低代码平台数据源管理实现