搜索

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

[板块4:可视化拖拽编辑器] - 09- 实现保存功能

代码示例
#可视化拖拽编辑器
## 实现保存功能

**目标**:在画布顶部添加保存按钮,点击后保存页面配置,并给用户成功提示。

---

### 修改文件:`resources/js/admin/views/PageEditor.vue`

---

### 修改位置1:导入 ElMessage 组件

**行号**:第93行附近,导入语句区域

**修改前**:

```typescript
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';
```

**修改后**:

```typescript
import { ref, onMounted, nextTick, watch } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import Sortable from 'sortablejs';
import { useComponentStore } from '../stores/component';
import type { ComponentType } from '../types/components';
```

**修改原因**:需要引入 Element Plus 的消息提示组件,在用户保存成功或失败时显示提示信息。

---

### 修改位置2:添加页面保存状态变量

**行号**:第115行附近,在 `saving` 变量声明之后

**修改前**:

```typescript
const saving = ref(false);
```

**修改后**:

```typescript
const saving = ref(false);
const savingPage = ref(false);  // 页面保存按钮的加载状态
```

**修改原因**:控制页面保存按钮的加载状态,防止用户在保存过程中重复点击。

---

### 修改位置3:添加页面保存方法

**行号**:第160行附近,在 `saveComponent` 方法之后

**修改前**:

```typescript
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;
    }
};
```

**修改后**:

```typescript
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 };
        ElMessage.success('组件保存成功');  // 新增:保存成功提示
    } catch (error) {
        ElMessage.error('组件保存失败');    // 新增:保存失败提示
    } finally {
        saving.value = false;
    }
};

// ========== 新增:保存页面 ==========
// 保存整个页面的配置(目前组件已实时保存,这里主要给用户反馈)
const savePage = async () => {
    savingPage.value = true;
    try {
        // 这里可以后续扩展保存页面本身的属性(如标题、SEO等)
        // 目前组件数据已经通过 addComponent/updateComponent/deleteComponent 实时保存
        await new Promise(resolve => setTimeout(resolve, 500));  // 模拟保存操作
        ElMessage.success('页面保存成功');
    } catch (error) {
        ElMessage.error('页面保存失败');
    } finally {
        savingPage.value = false;
    }
};
// ========== 新增结束 ==========
```

**修改原因**:
- 添加 `ElMessage.success` 和 `ElMessage.error` 给用户明确的操作反馈
- `savePage` 方法为后续扩展页面级别属性保存做准备,目前主要提供保存成功的心理反馈

---

### 修改位置4:画布标题栏添加保存按钮

**行号**:第25行附近,`.canvas-title` 区域

**修改前**:

```vue
<div>页面画布</div>
```

**修改后**:

```vue
<div>
    <span>页面画布</span>
    <el-button type="primary" size="small" @click="savePage" :loading="savingPage">
        保存页面
    </el-button>
</div>
```

**修改原因**:在画布标题栏右侧添加保存按钮,方便用户保存。`:loading="savingPage"` 让按钮在保存时显示加载动画。

---

### 修改位置5:添加标题栏 Flex 布局样式

**行号**:第200行附近,`.canvas-title` 样式区域

**修改前**:

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

**修改后**:

```css
.canvas-title {
    padding: 16px;
    font-weight: bold;
    border-bottom: 1px solid #e8e8e8;
    background: #fafafa;
    display: flex;
    justify-content: space-between;
    align-items: center;
}
```

**修改原因**:使用 Flex 布局让标题文字和保存按钮在同一行左右两端显示。

---

## 完整代码

**文件路径**:`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>
                <span>页面画布</span>
                <el-button type="primary" size="small" @click="savePage" :loading="savingPage">
                    保存页面
                </el-button>
            </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 { ElMessage } from 'element-plus';
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 savingPage = 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 };
        ElMessage.success('组件保存成功');
    } catch (error) {
        ElMessage.error('组件保存失败');
    } finally {
        saving.value = false;
    }
};

// 保存页面
const savePage = async () => {
    savingPage.value = true;
    try {
        await new Promise(resolve => setTimeout(resolve, 500));
        ElMessage.success('页面保存成功');
    } catch (error) {
        ElMessage.error('页面保存失败');
    } finally {
        savingPage.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;
}

.canvas-title {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.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