搜索

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

[板块4:可视化拖拽编辑器] - 07 - 阶段4实现属性编辑功能

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

##实现属性编辑

### 修改文件:`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>
```

🧸 adorable code

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

hello@adorablecode.com