# 可视化拖拽编辑器
##实现拖拽功能
### 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的完整操作记录。** 🚀