#可视化拖拽编辑器
##实现属性编辑
### 修改文件:`resources/js/admin/views/PageEditor.vue`
---
### 修改位置1:导入 watch 函数
**行号**:第93行附近
**修改前**:
```typescript
import { ref, onMounted, nextTick } from 'vue';
```
**修改后**:
```typescript
import { ref, onMounted, nextTick, watch } from 'vue';
```
**修改原因**:需要 `watch` 函数监听选中组件的变化,当用户点击不同组件时,自动将组件数据填充到右侧表单中。
---
### 修改位置2:添加选中状态和表单变量
**行号**:第100行附近,在 `componentLibrary` 定义之后
**修改前**:
```typescript
// 组件库列表
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: '联系表单' },
]);
```
**修改后**:
```typescript
// 组件库列表
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: '联系表单' },
]);
// ========== 新增:选中状态和表单变量 ==========
// 存储当前选中的组件对象和ID
const selectedComponent = ref<any>(null);
const selectedId = ref<number | null>(null);
// 右侧属性面板的表单数据,双向绑定到输入框
const editForm = ref({
title: '', // 组件标题
subtitle: '', // 副标题(横幅组件专用)
text: '', // 文本内容(文本组件专用)
image_url: '', // 图片地址(横幅、图片组件专用)
link_url: '', // 链接地址(横幅、图片组件专用)
});
const saving = ref(false); // 保存按钮的加载状态
// ========== 新增结束 ==========
```
**修改原因**:
- `selectedComponent`:记录用户点击的组件,用于高亮和表单绑定
- `selectedId`:用于判断哪个组件应该显示高亮边框
- `editForm`:右侧表单的数据源,与输入框双向绑定
- `saving`:防止用户快速重复点击保存按钮
---
### 修改位置3:添加选中、监听、保存方法
**行号**:第120行附近,在 `onDragStart` 函数之前
**修改前**:
```typescript
// 拖拽开始
const onDragStart = (evt: DragEvent, component: { type: ComponentType; name: string }) => {
// ...
};
```
**修改后**:
```typescript
// ========== 新增:选中组件 ==========
// 点击画布中的组件时触发,记录选中的组件
const selectComponent = (comp: any) => {
selectedComponent.value = comp;
selectedId.value = comp.id;
};
// ========== 新增:监听选中组件变化,自动填充表单 ==========
// 当 selectedComponent 变化时,自动将组件的内容填充到 editForm 中
watch(selectedComponent, (newVal) => {
if (newVal && newVal.content) {
// 有内容时,用组件数据填充表单
editForm.value = {
title: newVal.content.title || '',
subtitle: newVal.content.subtitle || '',
text: newVal.content.text || '',
image_url: newVal.content.image_url || '',
link_url: newVal.content.link_url || '',
};
} else {
// 没有选中组件时,清空表单
editForm.value = {
title: '',
subtitle: '',
text: '',
image_url: '',
link_url: '',
};
}
}, { immediate: true }); // immediate: true 让组件加载时立即执行一次
// ========== 新增:保存组件属性 ==========
// 将表单数据保存到数据库
const saveComponent = async () => {
if (!selectedComponent.value) return;
saving.value = true;
try {
// 调用 store 的更新方法,只更新 content 字段
await componentStore.updateComponent(selectedComponent.value.id, {
content: {
title: editForm.value.title,
subtitle: editForm.value.subtitle,
text: editForm.value.text,
image_url: editForm.value.image_url,
link_url: editForm.value.link_url,
},
});
// 更新本地缓存,避免重新从服务器加载
selectedComponent.value.content = { ...editForm.value };
} finally {
saving.value = false;
}
};
// ========== 新增结束 ==========
// 拖拽开始
const onDragStart = (evt: DragEvent, component: { type: ComponentType; name: string }) => {
// ...
};
```
**修改原因**:
- `selectComponent`:点击组件时更新选中状态,触发高亮和表单填充
- `watch`:自动将选中组件的数据同步到右侧表单,实现「点击组件 → 显示属性」
- `saveComponent`:将修改后的表单数据保存到数据库,并更新本地显示
---
### 修改位置4:画布组件绑定点击事件和高亮样式
**行号**:第30-40行附近,画布中的 `canvas-component` div
**修改前**:
```vue
<div
v-for="comp in componentStore.components"
:key="comp.id"
:data-id="comp.id"
>
```
**修改后**:
```vue
<div
v-for="comp in componentStore.components"
:key="comp.id"
:class="{ 'selected': selectedId === comp.id }"
:data-id="comp.id"
@click="selectComponent(comp)"
>
```
**修改原因**:
- `:class="{ 'selected': selectedId === comp.id }"`:当组件的ID等于选中的ID时,添加 `selected` 类名,触发高亮样式
- `@click="selectComponent(comp)"`:点击组件时调用 `selectComponent` 方法,记录选中状态
---
### 修改位置5:删除按钮阻止事件冒泡
**行号**:第35行附近,删除按钮
**修改前**:
```vue
<el-button type="danger" link size="small" @click="componentStore.deleteComponent(comp.id)">
删除
</el-button>
```
**修改后**:
```vue
<el-button type="danger" link size="small" @click.stop="componentStore.deleteComponent(comp.id)">
删除
</el-button>
```
**修改原因**:`@click.stop` 阻止点击删除按钮时触发父元素 `canvas-component` 的 `@click` 事件,避免删除组件的同时又选中它。
---
### 修改位置6:右侧属性面板完整替换
**行号**:第60-80行附近,`.property-content` 区域
**修改前**:
```vue
<div>
<p>点击组件编辑属性</p>
</div>
```
**修改后**:
```vue
<div>
<!-- 有选中组件时显示表单 -->
<div v-if="selectedComponent">
<el-form label-width="80px" :model="editForm">
<!-- 组件类型(只读显示) -->
<el-form-item label="组件类型">
<span>{{ getComponentName(selectedComponent.component_type) }}</span>
</el-form-item>
<!-- 标题(所有组件通用) -->
<el-form-item label="标题">
<el-input v-model="editForm.title" placeholder="请输入标题" />
</el-form-item>
<!-- 副标题(仅横幅组件显示) -->
<el-form-item label="副标题" v-if="selectedComponent.component_type === 'banner'">
<el-input v-model="editForm.subtitle" placeholder="请输入副标题" />
</el-form-item>
<!-- 内容(仅文本组件显示) -->
<el-form-item label="内容" v-if="selectedComponent.component_type === 'text'">
<el-input type="textarea" v-model="editForm.text" rows="4" placeholder="请输入内容" />
</el-form-item>
<!-- 图片地址(横幅、图片组件显示) -->
<el-form-item label="图片地址" v-if="['banner', 'image'].includes(selectedComponent.component_type)">
<el-input v-model="editForm.image_url" placeholder="例如: /images/banner.jpg" />
</el-form-item>
<!-- 链接地址(横幅、图片组件显示) -->
<el-form-item label="链接地址" v-if="['banner', 'image'].includes(selectedComponent.component_type)">
<el-input v-model="editForm.link_url" placeholder="例如: /about" />
</el-form-item>
<!-- 保存按钮 -->
<el-form-item>
<el-button type="primary" @click="saveComponent" :loading="saving">保存</el-button>
</el-form-item>
</el-form>
</div>
<!-- 无选中组件时显示提示 -->
<p v-else>点击组件编辑属性</p>
</div>
```
**修改原因**:
- `v-if="selectedComponent"`:只有选中组件时才显示表单
- `v-if="selectedComponent.component_type === 'banner'"`:不同组件类型显示不同的编辑字段
- `v-model="editForm.xxx"`:双向绑定表单数据
- `@click="saveComponent"`:保存按钮调用保存方法
- `:loading="saving"`:保存时按钮显示加载动画
---
### 修改位置7:添加高亮样式
**行号**:第200行附近,`<style scoped>` 内部
**修改前**:无
**修改后**:
```css
/* 选中组件的高亮样式 */
.canvas-component.selected {
border: 2px solid #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
```
**修改原因**:给选中的组件添加蓝色边框和发光阴影,让用户清楚地知道当前正在编辑哪个组件。
---
## 完整代码
**文件路径**:`resources/js/admin/views/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"
:class="{ 'selected': selectedId === comp.id }"
:data-id="comp.id"
@click="selectComponent(comp)"
>
<div>
<span>{{ getComponentName(comp.component_type) }}</span>
<el-button type="danger" link size="small" @click.stop="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>
<div v-if="selectedComponent">
<el-form label-width="80px" :model="editForm">
<el-form-item label="组件类型">
<span>{{ getComponentName(selectedComponent.component_type) }}</span>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="editForm.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="副标题" v-if="selectedComponent.component_type === 'banner'">
<el-input v-model="editForm.subtitle" placeholder="请输入副标题" />
</el-form-item>
<el-form-item label="内容" v-if="selectedComponent.component_type === 'text'">
<el-input type="textarea" v-model="editForm.text" rows="4" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="图片地址" v-if="['banner', 'image'].includes(selectedComponent.component_type)">
<el-input v-model="editForm.image_url" placeholder="例如: /images/banner.jpg" />
</el-form-item>
<el-form-item label="链接地址" v-if="['banner', 'image'].includes(selectedComponent.component_type)">
<el-input v-model="editForm.link_url" placeholder="例如: /about" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveComponent" :loading="saving">保存</el-button>
</el-form-item>
</el-form>
</div>
<p v-else>点击组件编辑属性</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, watch } 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 selectedComponent = ref<any>(null);
const selectedId = ref<number | null>(null);
const editForm = ref({
title: '',
subtitle: '',
text: '',
image_url: '',
link_url: '',
});
const saving = ref(false);
// ========== 结束 ==========
// ========== 选中组件 ==========
const selectComponent = (comp: any) => {
selectedComponent.value = comp;
selectedId.value = comp.id;
};
// ========== 结束 ==========
// ========== 监听选中组件变化,自动填充表单 ==========
watch(selectedComponent, (newVal) => {
if (newVal && newVal.content) {
editForm.value = {
title: newVal.content.title || '',
subtitle: newVal.content.subtitle || '',
text: newVal.content.text || '',
image_url: newVal.content.image_url || '',
link_url: newVal.content.link_url || '',
};
} else {
editForm.value = {
title: '',
subtitle: '',
text: '',
image_url: '',
link_url: '',
};
}
}, { immediate: true });
// ========== 结束 ==========
// ========== 保存组件属性 ==========
const saveComponent = async () => {
if (!selectedComponent.value) return;
saving.value = true;
try {
await componentStore.updateComponent(selectedComponent.value.id, {
content: {
title: editForm.value.title,
subtitle: editForm.value.subtitle,
text: editForm.value.text,
image_url: editForm.value.image_url,
link_url: editForm.value.link_url,
},
});
selectedComponent.value.content = { ...editForm.value };
} finally {
saving.value = false;
}
};
// ========== 结束 ==========
// 拖拽开始
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;
transition: all 0.2s;
}
/* 选中组件的高亮样式 */
.canvas-component.selected {
border: 2px solid #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.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>
```