搜索

📄 文章 📚 合集
热门搜索
🐘 PHP ⚡ Laravel 🎨 Vue.js ⚛️ React 📦 Yii 📘 JavaScript 🗄️ MySQL 🐳 Docker
返回合集

[板块4:可视化拖拽编辑器] - 05- 实现拖拽编辑器拖拽排序功能

代码示例
# 可视化拖拽编辑器

##实现拖拽功能

### 1. 目标

实现可视化编辑器的核心拖拽功能:
- 从左侧组件库拖拽组件到画布
- 画布内组件拖拽排序

### 2. 技术选型

| 功能 | 技术方案 | 选择原因 |
|------|---------|---------|
| 左侧拖拽到画布 | 原生 HTML5 拖拽 | 简单可靠,无需第三方库,兼容性好 |
| 画布内排序 | SortableJS | 专业拖拽排序库,API 简单,稳定成熟 |

### 3. 为什么不使用 vue-draggable-next

| 问题 | 说明 |
|------|------|
| 渲染问题 | 左侧组件库列表无法正常显示 |
| 兼容性问题 | 与 Vue 3 的兼容性不稳定 |
| 调试困难 | 报错信息不明确,难以定位 |

### 4. 安装依赖

```bash
npm install sortablejs
```

### 5. 修改文件清单

| 文件 | 操作 | 说明 |
|------|------|------|
| `PageEditor.vue` | 修改 | 添加拖拽功能 |
| `PageController.php` | 修改 | 修复创建页面时 site_id 缺失问题 |

---

## 6. PageEditor.vue 修改详情

### 6.1 文件路径

`resources\js\admin\views\PageEditor.vue`

### 6.2 修改内容

| 修改位置 | 修改内容 | 作用 |
|---------|---------|------|
| 导入语句 | 添加 `import Sortable from 'sortablejs'` | 引入排序库 |
| 导入语句 | 添加 `import { nextTick } from 'vue'` | 等待 DOM 渲染 |
| 变量声明 | 添加 `const canvasRef = ref<HTMLElement \| null>(null)` | 画布 DOM 引用 |
| 变量声明 | 添加 `let sortableInstance: Sortable \| null = null` | 排序实例 |
| 方法 | 添加 `onDragStart` | 拖拽开始,存储组件数据 |
| 方法 | 添加 `onDrop` | 放置组件,调用 API 添加 |
| 方法 | 添加 `initSortable` | 初始化画布内排序 |
| 生命周期 | 修改 `onMounted` | 加载组件后初始化排序 |
| 模板-左侧 | 添加 `draggable="true"` 和 `@dragstart` | 使组件可拖拽 |
| 模板-画布容器 | 添加 `ref="canvasRef"`、`@dragover.prevent`、`@drop` | 接收拖拽、绑定排序 |
| 模板-画布组件 | 添加 `:data-id="comp.id"` | 排序时获取组件 ID |

### 6.3 新增方法详解

**onDragStart(拖拽开始)**:

```typescript
const onDragStart = (evt: DragEvent, component: { type: ComponentType; name: string }) => {
    if (evt.dataTransfer) {
        // 将组件数据存入 dataTransfer
        evt.dataTransfer.setData('text/plain', JSON.stringify({
            type: component.type,
            name: component.name,
        }));
        evt.dataTransfer.effectAllowed = 'copy';
    }
};
```

**设计原因**:
- 使用 `dataTransfer.setData` 存储组件数据,实现跨元素传输
- `effectAllowed = 'copy'` 表示拖拽操作是复制

**onDrop(放置组件)**:

```typescript
const onDrop = async (evt: DragEvent) => {
    evt.preventDefault();
    const rawData = evt.dataTransfer?.getData('text/plain');
    if (!rawData) return;
    
    const component = JSON.parse(rawData);
    const newSortOrder = componentStore.components.length;
    
    await componentStore.addComponent({
        page_id: pageId,
        component_type: component.type,
        content: {},
        settings: {},
        sort_order: newSortOrder,
    });
};
```

**设计原因**:
- `preventDefault()` 阻止浏览器默认行为
- 从 `dataTransfer` 取出拖拽时存储的数据
- 新组件的排序值放在最后(`length`)

**initSortable(初始化画布排序)**:

```typescript
const initSortable = () => {
    if (!canvasRef.value) return;
    
    sortableInstance = new Sortable(canvasRef.value, {
        animation: 150,                    // 动画时长
        handle: '.canvas-component',       // 拖拽手柄
        onEnd: async () => {
            const items = canvasRef.value?.querySelectorAll('.canvas-component');
            if (!items) return;
            
            const sortedItems = Array.from(items).map((el, index) => ({
                id: Number(el.getAttribute('data-id')),
                sort_order: index,
            }));
            
            await componentStore.updateSortOrder(sortedItems);
        },
    });
};
```

**设计原因**:
- `animation: 150` 使排序有平滑动画
- `handle: '.canvas-component'` 只有组件区域可拖拽
- `onEnd` 在排序完成后触发,更新所有组件的 `sort_order`

### 6.4 完整 PageEditor.vue 代码

```vue
<template>
    <div>
        <!-- 左侧:组件库 -->
        <div>
            <div>组件库</div>
            <div>
                <div
                    v-for="item in componentLibrary"
                    :key="item.type"
                   
                    draggable="true"
                    @dragstart="onDragStart($event, item)"
                >
                    {{ item.name }}
                </div>
            </div>
        </div>

        <!-- 中间:画布 -->
        <div>
            <div>页面画布</div>
            <div
                ref="canvasRef"
               
                @dragover.prevent
                @drop="onDrop"
            >
                <div
                    v-for="comp in componentStore.components"
                    :key="comp.id"
                   
                    :data-id="comp.id"
                >
                    <div>
                        <span>{{ getComponentName(comp.component_type) }}</span>
                        <el-button type="danger" link size="small" @click="componentStore.deleteComponent(comp.id)">
                            删除
                        </el-button>
                    </div>
                    <div>
                        预览: {{ getComponentName(comp.component_type) }}
                    </div>
                </div>
                <p v-if="componentStore.components.length === 0">
                    从左侧拖拽组件到这里
                </p>
            </div>
        </div>

        <!-- 右侧:属性面板 -->
        <div>
            <div>属性面板</div>
            <div>
                <p>点击组件编辑属性</p>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import Sortable from 'sortablejs';
import { useComponentStore } from '../stores/component';
import type { ComponentType } from '../types/components';

const route = useRoute();
const pageId = Number(route.params.id);
const componentStore = useComponentStore();
const canvasRef = ref<HTMLElement | null>(null);
let sortableInstance: Sortable | null = null;

// 组件库列表
const componentLibrary = ref([
    { type: 'banner' as ComponentType, name: '横幅 Banner' },
    { type: 'text' as ComponentType, name: '纯文本' },
    { type: 'image' as ComponentType, name: '图片' },
    { type: 'multi_col' as ComponentType, name: '多列布局' },
    { type: 'map' as ComponentType, name: '地图' },
    { type: 'contact_form' as ComponentType, name: '联系表单' },
]);

// 拖拽开始
const onDragStart = (evt: DragEvent, component: { type: ComponentType; name: string }) => {
    if (evt.dataTransfer) {
        evt.dataTransfer.setData('text/plain', JSON.stringify({
            type: component.type,
            name: component.name,
        }));
        evt.dataTransfer.effectAllowed = 'copy';
    }
};

// 放置组件
const onDrop = async (evt: DragEvent) => {
    evt.preventDefault();
    const rawData = evt.dataTransfer?.getData('text/plain');
    if (!rawData) return;
    
    const component = JSON.parse(rawData);
    const newSortOrder = componentStore.components.length;
    
    await componentStore.addComponent({
        page_id: pageId,
        component_type: component.type,
        content: {},
        settings: {},
        sort_order: newSortOrder,
    });
};

// 初始化画布排序
const initSortable = () => {
    if (!canvasRef.value) return;
    
    sortableInstance = new Sortable(canvasRef.value, {
        animation: 150,
        handle: '.canvas-component',
        onEnd: async () => {
            const items = canvasRef.value?.querySelectorAll('.canvas-component');
            if (!items) return;
            
            const sortedItems = Array.from(items).map((el, index) => ({
                id: Number(el.getAttribute('data-id')),
                sort_order: index,
            }));
            
            await componentStore.updateSortOrder(sortedItems);
        },
    });
};

// 获取组件显示名称
const getComponentName = (type: ComponentType) => {
    const map: Record<ComponentType, string> = {
        banner: '横幅 Banner',
        text: '纯文本',
        image: '图片',
        multi_col: '多列布局',
        map: '地图',
        contact_form: '联系表单',
    };
    return map[type] || type;
};

onMounted(async () => {
    await componentStore.fetchComponents(pageId);
    await nextTick();
    initSortable();
});
</script>

<style scoped>
.editor-container {
    display: flex;
    height: calc(100vh - 120px);
    gap: 16px;
}

.editor-sidebar {
    width: 260px;
    background: #fff;
    border-radius: 8px;
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.editor-canvas {
    flex: 1;
    background: #fff;
    border-radius: 8px;
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.editor-property {
    width: 320px;
    background: #fff;
    border-radius: 8px;
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.sidebar-title,
.canvas-title,
.property-title {
    padding: 16px;
    font-weight: bold;
    border-bottom: 1px solid #e8e8e8;
    background: #fafafa;
}

.component-list {
    padding: 16px;
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.component-item {
    padding: 12px;
    background: #f5f5f5;
    border-radius: 6px;
    cursor: grab;
    text-align: center;
    transition: all 0.2s;
}

.component-item:hover {
    background: #e6f7ff;
    border-color: #1890ff;
}

.canvas-content {
    flex: 1;
    padding: 16px;
    overflow-y: auto;
    min-height: 200px;
}

.canvas-component {
    background: #fafafa;
    border: 1px solid #e8e8e8;
    border-radius: 6px;
    margin-bottom: 12px;
    overflow: hidden;
    cursor: grab;
}

.component-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px 12px;
    background: #f5f5f5;
    border-bottom: 1px solid #e8e8e8;
}

.component-preview {
    padding: 20px;
    text-align: center;
    color: #666;
}

.empty-tip {
    text-align: center;
    color: #999;
    padding: 40px;
}

.property-content {
    flex: 1;
    padding: 16px;
    overflow-y: auto;
}
</style>
```

---

## 7. 修复创建页面失败问题

### 7.1 问题描述

创建页面时报错:

```
Field 'site_id' doesn't have a default value
```

### 7.2 原因分析

`pages` 表有 `site_id` 字段(外键),但 `PageController` 的 `store` 方法没有传递该值。

### 7.3 解决方案

修改 `PageController.php`,自动获取或创建站点。

**文件路径**:`app/Http/Controllers/Admin/PageController.php`

**修改前**:

```php
public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|string|max:255',
        'slug' => 'required|string|max:255|unique:pages',
        'is_home' => 'boolean',
        'status' => 'boolean',
    ]);

    $page = Page::create($validated);
    return response()->json($page, 201);
}
```

**修改后**:

```php
public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|string|max:255',
        'slug' => 'required|string|max:255|unique:pages',
        'is_home' => 'boolean',
        'status' => 'boolean',
    ]);

    // 获取第一个站点,如果没有则创建默认站点
    $site = \App\Models\Site::first();
    if (!$site) {
        $site = \App\Models\Site::create([
            'site_name' => '好站站企业官网',
            'site_logo' => '/logo.png',
        ]);
    }
    
    $validated['site_id'] = $site->id;
    $page = Page::create($validated);
    
    return response()->json($page, 201);
}
```

---

## 8. 验证结果

| 验证项 | 预期结果 | 实际结果 |
|--------|---------|---------|
| 左侧组件库显示 | 显示 6 个组件 | ✅ |
| 拖拽组件到画布 | 画布显示新组件 | ✅ |
| 画布内拖拽排序 | 组件顺序改变 | ✅ |
| 删除组件 | 组件从画布移除 | ✅ |
| 创建页面 | 成功创建,不报错 | ✅ |
| 编译 | 无报错 | ✅ |


---

**以上是板块3-3的完整操作记录。** 🚀

🧸 adorable code

专注 PHP、JavaScript、Laravel、Vue.js、React、Yii 全栈开发。记录技术探索过程中的灵感与经验,分享工程实践洞见。

hello@adorablecode.com