Vue3 低代码平台项目实战(下)

低代码平台项目实战(下)

  • [10. 搭建编辑器](#10. 搭建编辑器)
    • [10.1 绘制编辑器基本结构](#10.1 绘制编辑器基本结构)
    • [10.2 完成题型和大纲切换](#10.2 完成题型和大纲切换)
    • [10.3 完成题型面板绘制](#10.3 完成题型面板绘制)
  • [11. 往画布添加组件](#11. 往画布添加组件)
    • [11.1 创建编辑器数据仓库](#11.1 创建编辑器数据仓库)
    • [11.2 画布中添加组件](#11.2 画布中添加组件)
    • [11.3 使用事件总线实现丝滑滚动](#11.3 使用事件总线实现丝滑滚动)
  • [12. 编辑画布组件](#12. 编辑画布组件)
    • [12.1 选中画布组件,展示编辑面板](#12.1 选中画布组件,展示编辑面板)
    • [12.2 完成编辑功能](#12.2 完成编辑功能)
    • [12.3 解决警告](#12.3 解决警告)
  • [13. vuedraggable 拖拽组件库](#13. vuedraggable 拖拽组件库)
    • [13.1 vuedraggable 简介](#13.1 vuedraggable 简介)
    • [13.2 使用方式](#13.2 使用方式)
    • [13.3 使用示例](#13.3 使用示例)
  • [14. 拖动组件](#14. 拖动组件)
    • [14.1 拖动画布中组件](#14.1 拖动画布中组件)
    • [14.2 绘制大纲组件,并实现拖拽](#14.2 绘制大纲组件,并实现拖拽)
    • [14.3 解决计算序号问题](#14.3 解决计算序号问题)
    • [14.4 点击提纲,画布对应组件滚动居中](#14.4 点击提纲,画布对应组件滚动居中)
    • [14.5 添加删除功能](#14.5 添加删除功能)
    • [14.6 修复警告](#14.6 修复警告)
  • [15. 存储问卷](#15. 存储问卷)
    • [15.1 dexie 简介(IndexedDB API 的轻量级 JS 库)](#15.1 dexie 简介(IndexedDB API 的轻量级 JS 库))
    • [15.2 使用 dexie 定义和操作 IndexedDB](#15.2 使用 dexie 定义和操作 IndexedDB)
    • [15.3 重置问卷](#15.3 重置问卷)
    • [15.4 保存问卷](#15.4 保存问卷)
    • [15.5 显示问卷列表](#15.5 显示问卷列表)
  • [16. 预览问卷](#16. 预览问卷)
    • [16.1 创建预览问卷页面](#16.1 创建预览问卷页面)
    • [16.2 还原问卷数据](#16.2 还原问卷数据)
    • [16.3 绘制预览页面](#16.3 绘制预览页面)
  • [17. 编辑和删除问卷](#17. 编辑和删除问卷)
    • [17.1 编辑问卷](#17.1 编辑问卷)
    • [17.2 删除问卷](#17.2 删除问卷)
  • [18. 生成PDF和在线问卷](#18. 生成PDF和在线问卷)
    • [18.1 生成PDF](#18.1 生成PDF)
    • [18.2 生成在线问卷](#18.2 生成在线问卷)
  • [19. 课程收官](#19. 课程收官)
    • [19.1 代码优化方向](#19.1 代码优化方向)
    • [19.2 完整代码项目](#19.2 完整代码项目)
    • [19.3 完整的低代码项目涉及技术](#19.3 完整的低代码项目涉及技术)

10. 搭建编辑器

上面我们已经初步完成了组件市场的功能,其他业务组件,同学们根据需要进行创建和完善即可。

接下来,我们就要投入编辑器的搭建工作了。


10.1 绘制编辑器基本结构

编辑器除了头部外,下面基本分为左中右结构,但是左右两个区域区别于中间画布,需要根据浏览器进行定位,所以需要切分出一个容器。另外左侧相对复杂,需要单独一个文件夹进行处理。

(1)修改 views/EditorView/Index.vue:

javascript 复制代码
<template>
  <div>
    <div class="header">
      <Header />
    </div>
    <!-- 编辑器主体区域 -->
    <div class="container">
      <LeftSide />
      <RightSide />
    </div>
    <div>
      <Center />
    </div>
  </div>
</template>

<script setup lang="ts">
import Header from '@/components/Common/Header.vue';
import LeftSide from '@/views/EditorView/LeftSide/Index.vue';
import Center from '@/views/EditorView/Center.vue';
import RightSide from '@/views/EditorView/RightSide.vue';
</script>

<style scoped lang="scss">
.header {
  width: 100%;
  background-color: var(--white);
  position: fixed;
  top: 0;
  z-index: 10;
}
.container {
  width: calc(100vw - 40px);
  padding: 20px;
  // Header的高度50px,上下padding 20px
  height: calc(100vh - 50px - 40px);
  background: url('@/assets/imgs/editor_background.png');
  position: fixed;
  top: 50px;
}
</style>

(2)创建左侧组件 views/EditorView/LeftSide/Index.vue:

javascript 复制代码
<template>
  <div class="left-side-container flex">
    <div class="tabs">
      <!-- 题型 -->
      <div class="tab-item">
        <el-icon>
          <Memo />
        </el-icon>
        <span class="tab-item-title mt-5">题型</span>
      </div>
      <!-- 大纲 -->
      <div class="tab-item">
        <el-icon>
          <Document />
        </el-icon>
        <span class="tab-item-title mt-5">大纲</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Document, Memo } from '@element-plus/icons-vue';
</script>

<style scoped lang="scss">
.left-side-container {
  width: 300px;
  height: calc(100vh - 50px - 40px);
  position: fixed;
  left: 20px;
  top: 70px;
  background: var(--white);
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);

  >.tabs {
    width: 20%;
    height: 100%;
    border-right: 1px solid var(--border-color);

    >.tab-item {
      width: 100%;
      height: 100px;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-direction: column;
      color: var(--font-color-light);
      text-decoration: none;
      cursor: pointer;

      >.tab-item-title {
        font-size: var(--font-size-base);
      }
    }

    >.tab-show {
      color: var(--primary-color);
    }
  }

  >.tab-pane {
    width: 80%;
    // 高度需要减去padding部分,否则会溢出
    height: calc(100% - 50px);
    padding: 25px;
    overflow-y: scroll;
  }
}
</style>

(3)创建右侧组件 views/EditorView/RightSide.vue:

javascript 复制代码
<template>
  <div class="right-side-container">right side</div>
</template>

<script setup lang="ts"></script>

<style scoped lang="scss">
.right-side-container {
  width: 320px;
  height: calc(100vh - 50px - 40px);
  position: fixed;
  right: 20px;
  top: 70px;
  background-color: var(--white);
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  overflow-y: scroll;
}
.content {
  height: 100%;
}
</style>

(4)创建中间画布组件 views/EditorView/Center.vue:

javascript 复制代码
<template>
  <div class="center-container">画布区域</div>
</template>

<script setup lang="ts"></script>

<style scoped>
.center-container {
  width: 50%;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  margin: 70px auto;
  padding: 20px;
  background: var(--white);
  position: relative;
  .content {
    cursor: pointer;
    padding: 10px;
    background-color: var(--white);
    border-radius: var(--border-radius-sm);
    &:hover {
      transform: scale(1.01);
      transition: 0.5s;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
  }
}
.active {
  transform: scale(1.01);
  transition: 0.5s;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.delete-btn {
  right: -5px;
  top: -10px;
}
</style>

此时页面已经初具雏形。

10.2 完成题型和大纲切换

(1)配置路由 router/index.ts 关键代码:

javascript 复制代码
{
      path: '/editor',
      name: 'editor',
      component: () => import('@/views/EditorView/Index.vue'),
      children: [
        {
          path: 'survey-type',
          name: 'survey-type',
          component: () => import('@/views/EditorView/LeftSide/SurveyType.vue'),
        },
        {
          path: 'outline',
          name: 'outline',
          component: () => import('@/views/EditorView/LeftSide/Outline.vue'),
        },
      ],
    },

(2)创建题型面板组件 views/EditorView/LeftSide/SurveyType.vue:

javascript 复制代码
<template>
  <div>题型面板</div>
</template>

<script setup lang="ts"></script>

<style scoped></style>

(3)创建大纲面板组件 views/EditorView/LeftSide/Outline.vue:

javascript 复制代码
<template>
  <div>大纲面板</div>
</template>

<script setup lang="ts"></script>

<style scoped></style>

(4)左侧组件添加路由切换功能 views/EditorView/LeftSide\Index.vue:

javascript 复制代码
<template>
  <div class="left-side-container flex">
    <div class="tabs">
      <!-- 题型 -->
      <div
        class="tab-item"
        :class="{
          'tab-show': routeName === 'survey-type',
        }"
        @click="switchEditor"
      >
        <el-icon><Memo /></el-icon>
        <span class="tab-item-title mt-5">题型</span>
      </div>
      <!-- 大纲 -->
      <div
        class="tab-item"
        :class="{
          'tab-show': routeName === 'outline',
        }"
        @click="switchOutline"
      >
        <el-icon><Document /></el-icon>
        <span class="tab-item-title mt-5">大纲</span>
      </div>
    </div>
    <RouterView class="tab-pane" />
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { Document, Memo } from '@element-plus/icons-vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const routeName = computed(() => route.name);
const router = useRouter();

const switchEditor = () => {
  router.push({ name: 'survey-type' });
};

const switchOutline = () => {
  router.push({ name: 'outline' });
};
</script>

<style scoped lang="scss">
.left-side-container {
  width: 300px;
  height: calc(100vh - 50px - 40px);
  position: fixed;
  left: 20px;
  top: 70px;
  background: var(--white);
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  > .tabs {
    width: 20%;
    height: 100%;
    border-right: 1px solid var(--border-color);
    > .tab-item {
      width: 100%;
      height: 100px;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-direction: column;
      color: var(--font-color-light);
      text-decoration: none;
      cursor: pointer;
      > .tab-item-title {
        font-size: var(--font-size-base);
      }
    }
    > .tab-show {
      color: var(--primary-color);
    }
  }
  > .tab-pane {
    width: 80%;
    // 高度需要减去padding部分,否则会溢出
    height: calc(100% - 50px);
    padding: 25px;
    overflow-y: scroll;
  }
}
</style>


10.3 完成题型面板绘制

(1)添加题型面板配置文件 configs/SurveyGroupConfig.ts:

javascript 复制代码
// 该文件是题型面板对应的配置文件,用于配置题型面板的题型信息
import { CircleCheck, ChatLineSquare, User } from '@element-plus/icons-vue';
export const SurveyComsList = [
  {
    title: '选择题',
    icon: CircleCheck,
    list: [
      { materialName: 'single-select', comName: '单选题' },
      { materialName: 'single-pic-select', comName: '图片单选' },
    ],
  },
  {
    title: '备注说明',
    icon: ChatLineSquare,
    list: [{ materialName: 'text-note', comName: '备注说明' }],
  },
  {
    title: '个人信息',
    icon: User,
    list: [
      { materialName: 'personal-info-gender', comName: '性别' },
      { materialName: 'personal-info-education', comName: '学历' },
    ],
  },
];

(2)创建题型项组件 components/Editor/SurveyComItem.vue:

javascript 复制代码
<template>
  <div>
    <div
      class="survey-com-item-container pointer flex justify-content-center align-items-center self-center pl-10 pr-10 mb-10">
      {{ item.comName }}
    </div>
  </div>
</template>

<script setup lang="ts">
defineProps(['item']);
</script>

<style scoped lang="scss">
.survey-com-item-container {
  width: 60px;
  height: 30px;
  background-color: var(--background-color);
  border-radius: var(--border-radius-md);
  font-size: var(--font-size-base);
  color: var(--font-color-light);
  user-select: none;
}

.survey-com-item-container:hover {
  background-color: var(--font-color-lightest);
}
</style>

(3)创建题型组组件 components/Editor/SurveyComGroup.vue:

javascript 复制代码
<template>
  <div class="survey-com-group-container mc">
    <div class="mb-20">
      <!-- 分组标题和图标 -->
      <div class="group-title font-weight-500 mb-15 flex align-items-center">
        <el-icon>
          <component :is="icon" />
        </el-icon>
        <div class="ml-5 title">{{ title }}</div>
      </div>
      <!-- 该分组对应的业务组件 -->
      <div class="flex wrap space-between">
        <SurveyComItem v-for="(item, index) in list" :key="index" :item="item" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import SurveyComItem from './SurveyComItem.vue';
defineProps(['title', 'icon', 'list']);
</script>

<style scoped lang="scss">
.survey-com-group-container {
  width: 90%;
}

.title {
  font-size: var(--font-size-base);
  position: relative;
  bottom: 1px;
}
</style>

(4)绘制题型面板 views/EditorView/LeftSide\SurveyType.vue:

javascript 复制代码
<template>
  <div class="survey-type-container">
    <SurveyComGroup v-for="(group, index) in SurveyComsList" :key="index" v-bind="group" />
  </div>
</template>

<script setup lang="ts">
import SurveyComGroup from '@/components/Editor/SurveyComGroup.vue';
import { SurveyComsList } from '@/configs/SurveyGroupConfig';
</script>

<style scoped lang="scss">
.survey-type-container {
  height: calc(100vh - 50px - 40px);
  overflow: hidden;
}
</style>

11. 往画布添加组件

11.1 创建编辑器数据仓库

(1)创建 stores/useEditor.ts:

javascript 复制代码
// 该仓库用于存储画布的状态

import { defineStore } from 'pinia';
import type { Status } from '@/types';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setCurrentStatus,
  setPicLinkByIndex,
} from './actions';

export const useEditorStore = defineStore('editor', {
  state: () => ({
    currentComponentIndex: -1, // 当前选中的组件索引,一开始都没有选中,所以是-1
    surveyCount: 0, // 问卷题目的数量
    coms: [] as Status[], // 问卷题目的数组
  }),
  actions: {
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setCurrentStatus,
    setPicLinkByIndex,
  },
});

(2)在编辑器主页引入数据仓库 views/EditorView/Index.vue:

javascript 复制代码
<template>
  <div>
    <div class="header">
      <Header />
    </div>
    <!-- 编辑器主体区域 -->
    <div class="container">
      <LeftSide />
      <RightSide />
    </div>
    <div>
      <Center />
    </div>
  </div>
</template>

<script setup lang="ts">
import Header from '@/components/Common/Header.vue';
import LeftSide from '@/views/EditorView/LeftSide/Index.vue';
import Center from '@/views/EditorView/Center.vue';
import RightSide from '@/views/EditorView/RightSide.vue';

// 仓库
import { useEditorStore } from '@/stores/useEditor';
useEditorStore();
</script>

<style scoped lang="scss">
.header {
  width: 100%;
  background-color: var(--white);
  position: fixed;
  top: 0;
  z-index: 10;
}
.container {
  width: calc(100vw - 40px);
  padding: 20px;
  // Header的高度50px,上下padding 20px
  height: calc(100vh - 50px - 40px);
  background: url('@/assets/imgs/editor_background.png');
  position: fixed;
  top: 50px;
}
</style>

11.2 画布中添加组件

(1)添加当前判断是否为题目类型方法。 types/store.ts 关键代码:

javascript 复制代码
// 记录题目类型的数组
export const SurveyComNameArr = [
  'single-select',
  'single-pic-select',
  'personal-info-gender',
  'personal-info-education',
];

// 判断传入的值是否为题目类型
export function isSurveyComName(value: string): value is SurveyComName {
  return SurveyComNameArr.includes(value as SurveyComName);
}

(2)数据仓库添加新增问卷题目方法。 stores\useEditor.ts

javascript 复制代码
// 该仓库用于存储画布的状态

import { defineStore } from 'pinia';
import type { Status } from '@/types';
import { isSurveyComName } from '@/types';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setCurrentStatus,
  setPicLinkByIndex,
} from './actions';

export const useEditorStore = defineStore('editor', {
  state: () => ({
    currentComponentIndex: -1, // 当前选中的组件索引,一开始都没有选中,所以是-1
    surveyCount: 0, // 问卷题目的数量
    coms: [] as Status[], // 问卷题目的数组
  }),
  actions: {
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setCurrentStatus,
    setPicLinkByIndex,
    addCom(newCom: Status) {
      this.coms.push(newCom);
      this.currentComponentIndex = -1;
      if (isSurveyComName(newCom.name)) this.surveyCount++;
    },
  },
});

(3)题型组件使用新增组件方法。components/Editor/SurveyComItem.vue:

javascript 复制代码
<template>
  <div>
    <div
      class="survey-com-item-container pointer flex justify-content-center align-items-center self-center pl-10 pr-10 mb-10"
      @click="addSurveyCom"
    >
      {{ item.comName }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import { updateInitStatusBeforeAdd } from '@/utils';
import type { Material, Status } from '@/types';
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();

const props = defineProps(['item']);
const addSurveyCom = () => {
  const newSurveyComName = props.item.materialName as Material;
  if (!newSurveyComName) {
    console.warn('请先选择题型');
    return;
  }
  const newSurveyComStatus = defaultStatusMap[newSurveyComName]() as Status;
  updateInitStatusBeforeAdd(newSurveyComStatus, newSurveyComName);
  store.addCom(newSurveyComStatus);
};
</script>

<style scoped lang="scss">
.survey-com-item-container {
  width: 60px;
  height: 30px;
  background-color: var(--background-color);
  border-radius: var(--border-radius-md);
  font-size: var(--font-size-base);
  color: var(--font-color-light);
  user-select: none;
}
.survey-com-item-container:hover {
  background-color: var(--font-color-lightest);
}
</style>

(4)画布区域关联编辑器数据仓库。views/EditorView/Center.vue:

javascript 复制代码
<template>
  <div ref="centerContainer" class="center-container">
    <div v-for="(item, index) in store.coms" :key="index">
      <component :is="item.type" :status="item.status" :serialNum="1" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
</script>

<style scoped>
.center-container {
  width: 50%;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  margin: 70px auto;
  padding: 20px;
  background: var(--white);
  position: relative;
  .content {
    cursor: pointer;
    padding: 10px;
    background-color: var(--white);
    border-radius: var(--border-radius-sm);
    &:hover {
      transform: scale(1.01);
      transition: 0.5s;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
  }
}
.active {
  transform: scale(1.01);
  transition: 0.5s;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.delete-btn {
  right: -5px;
  top: -10px;
}
</style>

11.3 使用事件总线实现丝滑滚动

(1)下载 mitt 依赖。

javascript 复制代码
pnpm i mitt

(2)定义事件总线类型。创建 types\eventBus.ts

javascript 复制代码
export type EventBus = {
  scrollToBottom: void;
};

(3)types/index.ts 中引入:

javascript 复制代码
export * from './editProps';
export * from './common';
export * from './store';
export * from './eventBus';

(4)创建事件总线实例。utils/eventBus.ts

javascript 复制代码
// 提供事件总线
import mitt from 'mitt';
import type { EventBus } from '@/types';
const emitter = mitt<EventBus>();
export default emitter;

(5)新增题型时触发。components/Editor/SurveyComItem.vue:

javascript 复制代码
<template>
  <div>
    <div
      class="survey-com-item-container pointer flex justify-content-center align-items-center self-center pl-10 pr-10 mb-10"
      @click="addSurveyCom"
    >
      {{ item.comName }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import { updateInitStatusBeforeAdd } from '@/utils';
import type { Material, Status } from '@/types';
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 事件总线
import EventBus from '@/utils/eventBus';

const props = defineProps(['item']);
const addSurveyCom = () => {
  const newSurveyComName = props.item.materialName as Material;
  if (!newSurveyComName) {
    console.warn('请先选择题型');
    return;
  }
  const newSurveyComStatus = defaultStatusMap[newSurveyComName]() as Status;
  updateInitStatusBeforeAdd(newSurveyComStatus, newSurveyComName);
  store.addCom(newSurveyComStatus);
  // 每次添加了新的组件,都要滚动到底部
  EventBus.emit('scrollToBottom');
};
</script>

<style scoped lang="scss">
.survey-com-item-container {
  width: 60px;
  height: 30px;
  background-color: var(--background-color);
  border-radius: var(--border-radius-md);
  font-size: var(--font-size-base);
  color: var(--font-color-light);
  user-select: none;
}
.survey-com-item-container:hover {
  background-color: var(--font-color-lightest);
}
</style>

(6)画布页面接收并自定义滚动事件。views/EditorView/Center.vue:

javascript 复制代码
<template>
  <div ref="centerContainer" class="center-container">
    <div v-for="(item, index) in store.coms" :key="index">
      <component :is="item.type" :status="item.status" :serialNum="1" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 事件总线
import EventBus from '@/utils/eventBus';

const centerContainer = ref<HTMLElement | null>(null);

const scrollToBottom = () => {
  nextTick(() => {
    const container = centerContainer.value; // 获取容器的dom元素
    if (container) {
      window.scrollTo({
        top: container.scrollHeight,
        behavior: 'smooth',
      });
    }
  });
};
// 通过事件总线提供滚动方法给外部调用
EventBus.on('scrollToBottom', scrollToBottom);
</script>

<style scoped>
.center-container {
  width: 50%;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  margin: 70px auto;
  padding: 20px;
  background: var(--white);
  position: relative;
  .content {
    cursor: pointer;
    padding: 10px;
    background-color: var(--white);
    border-radius: var(--border-radius-sm);
    &:hover {
      transform: scale(1.01);
      transition: 0.5s;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
  }
}
.active {
  transform: scale(1.01);
  transition: 0.5s;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.delete-btn {
  right: -5px;
  top: -10px;
}
</style>

可以发现,每当添加新题型时,中间画布都会跟随滚动到底部。

12. 编辑画布组件

12.1 选中画布组件,展示编辑面板

(1)数据仓库添加设置选中组件方法。stores/useEditor.ts 关键代码:

javascript 复制代码
actions: {
  // 其他代码省略
  setCurrentComponentIndex(index: number) {
    this.currentComponentIndex = index;
  },
},

(2)画布设置选中方法,并优化选中效果。views/EditorView/Center.vue:

javascript 复制代码
<template>
  <div ref="centerContainer" class="center-container">
    <div
      v-for="(item, index) in store.coms"
      :key="index"
      class="content mb-10 relative"
      :class="{
        active: store.currentComponentIndex === index,
      }"
      @click="clickHandle(index)"
    >
      <component :is="item.type" :status="item.status" :serialNum="1" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 事件总监
import EventBus from '@/utils/eventBus';

const centerContainer = ref<HTMLElement | null>(null);

const scrollToBottom = () => {
  nextTick(() => {
    const container = centerContainer.value; // 获取容器的dom元素
    if (container) {
      window.scrollTo({
        top: container.scrollHeight,
        behavior: 'smooth',
      });
    }
  });
};
// 通过事件总线提供滚动方法给外部调用
EventBus.on('scrollToBottom', scrollToBottom);

const clickHandle = (index: number) => {
  if (store.currentComponentIndex === index) {
    store.setCurrentComponentIndex(-1);
  } else {
    store.setCurrentComponentIndex(index);
  }
};
</script>

<style scoped>
.center-container {
  width: 50%;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  margin: 70px auto;
  padding: 20px;
  background: var(--white);
  position: relative;
  .content {
    cursor: pointer;
    padding: 10px;
    background-color: var(--white);
    border-radius: var(--border-radius-sm);
    &:hover {
      transform: scale(1.01);
      transition: 0.5s;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
  }
}
.active {
  transform: scale(1.01);
  transition: 0.5s;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.delete-btn {
  right: -5px;
  top: -10px;
}
</style>

(3)展示右侧编辑面板。views/EditorView/RightSide.vue:

javascript 复制代码
<template>
  <div class="right-side-container">
    <div
      v-if="store.currentComponentIndex === -1"
      class="content flex justify-content-center align-items-center"
    >
      点击题型进行编辑
    </div>
    <div v-else>
      <EditPannel :com="currentCom" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
import EditPannel from '@/components/SurveyComs/EditItems/EditPannel.vue';

const currentCom = computed(() => store.coms[store.currentComponentIndex]);

</script>

<style scoped lang="scss">
.right-side-container {
  width: 320px;
  height: calc(100vh - 50px - 40px);
  position: fixed;
  right: 20px;
  top: 70px;
  background-color: var(--white);
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  overflow-y: scroll;
}
.content {
  height: 100%;
}
</style>

12.2 完成编辑功能

完成画布编辑功能。views/EditorView/RightSide.vue:

javascript 复制代码
<template>
  <div class="right-side-container">
    <div
      v-if="store.currentComponentIndex === -1"
      class="content flex justify-content-center align-items-center"
    >
      点击题型进行编辑
    </div>
    <div v-else>
      <EditPannel :com="currentCom" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, provide } from 'vue';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
import EditPannel from '@/components/SurveyComs/EditItems/EditPannel.vue';

import { ElMessage } from 'element-plus';
import type { PicLink } from '@/types';
import { isPicLink, IsTypeStatus, IsOptionsStatus } from '@/types';
import { changeEditorIsShowStatus } from '@/utils';

const currentCom = computed(() => store.coms[store.currentComponentIndex]);

const updateStatus = (configKey: string, payload?: number | string | boolean | PicLink) => {
  // 拿到新的状态数据之后,就应该去修改仓库里面的数据
  switch (configKey) {
    case 'type': {
      if (typeof payload === 'number' && IsTypeStatus(currentCom.value.status)) {
        // 切换其他编辑器的显示状态
        changeEditorIsShowStatus(currentCom.value.status, payload);
        store.setCurrentStatus(currentCom.value.status[configKey], payload);
      }
      break;
    }
    case 'title':
    case 'desc': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "title or desc". Expected string.');
        return;
      }
      store.setTextStatus(currentCom.value.status[configKey], payload);
      break;
    }
    case 'options': {
      if (IsOptionsStatus(currentCom.value.status))
        if (typeof payload === 'number') {
          // 说明是删除选项
          const result = store.removeOption(currentCom.value.status[configKey], payload);
          if (result) ElMessage.success('删除成功');
          else ElMessage.error('至少保留两个选项');
        } else if (typeof payload === 'object' && isPicLink(payload)) {
          // 说明是在设置图片的链接
          store.setPicLinkByIndex(currentCom.value.status[configKey], payload);
        } else {
          // 说明是新增选项
          store.addOption(currentCom.value.status[configKey]);
        }
      break;
    }
    case 'position': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "position". Expected number.');
        return;
      }
      store.setPosition(currentCom.value.status[configKey], payload);
      break;
    }
    case 'titleSize':
    case 'descSize': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "titleSize or descSize". Expected number.');
        return;
      }
      store.setCurrentStatus(currentCom.value.status[configKey], payload);
      break;
    }
  }
};

const getLink = (link: PicLink) => {
  updateStatus('options', link);
};

provide('updateStatus', updateStatus);
provide('getLink', getLink);
</script>

<style scoped lang="scss">
.right-side-container {
  width: 320px;
  height: calc(100vh - 50px - 40px);
  position: fixed;
  right: 20px;
  top: 70px;
  background-color: var(--white);
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  overflow-y: scroll;
}
.content {
  height: 100%;
}
</style>

12.3 解决警告

componentsSurveyComs/Common/PicItem.vue 关键代码:

javascript 复制代码
const getLink = inject<GetLink>('getLink', () => {});

13. vuedraggable 拖拽组件库

13.1 vuedraggable 简介

最新版本链接:https://www.npmjs.com/package/vuedraggable/v/4.1.0

vuedraggable 是一个基于 Sortable.js 的 Vue 组件,用于实现拖拽排序功能。它可以让你在 Vue 应用中轻松地实现拖拽排序,并提供了丰富的配置选项和事件来控制拖拽行为。

Sortable.js 是一个轻量级的 JS 库,用于实现拖拽排序功能。它支持 HTML5 的拖拽 API,并提供了丰富的选项和事件,可以轻松地在网页中实现拖拽排序、拖拽交换等功能。Vue.Draggable 是基于 Sortable.js 构建的,用于在 Vue 应用中实现这些功能。

Sortable.js 的特点:

  1. 轻量级:Sortable.js 非常轻量,核心库只有几千字节。
  2. 高性能:利用现代浏览器的 HTML5 拖拽 API,提供高性能的拖拽体验。
  3. 多样的选项:提供丰富的选项和回调函数,可以自定义拖拽行为。
  4. 多种场景:支持多种拖拽场景,包括列表排序、网格布局、分组拖拽等。
  5. 与框架集成:容易与主流前端框架集成,如 Vue、React、Angular 等。

13.2 使用方式

(1)下载最新依赖:

bash 复制代码
npm install vuedraggable@next

安装的版本信息:"vuedraggable": "^4.1.0"

注意这里在默认安装的时候不会安装此版本,使用时会有一些问题,务必安装 4.1.0 版本

(2)安装后可以从这个库中导入一个组件:

javascript 复制代码
<template>
	<draggable 
     v-model="myArray" 
     group="people" 
     @start="drag=true" 
     @end="drag=false" 
     itemKey="id"
   >
    <template #item="{ element }">
      <div class="task">{{ element.name }}</div>
    </template>
  </draggable>
</template>

<script setup>
import draggable from 'vuedraggable'
</script>

13.3 使用示例

这是 vuedraggable 的一个标准用法。

  1. v-model="myArray":
    • v-model 是 Vue 的双向数据绑定语法糖。在这里,它绑定了一个数组 myArray,这个数组包含了需要拖拽排序的元素。
    • 当数组的顺序改变时(由于拖拽),myArray 会自动更新以反映新的顺序。
  2. group="people":
    • group 属性用于配置分组,可以在不同的 draggable 实例之间进行拖拽操作。
    • 相同 group 名称的 draggable 实例之间允许相互拖拽元素。在这个例子中,所有 group 为 people 的 draggable 实例之间都可以互相拖拽元素。
  3. @start="drag=true":
    • @start 是一个事件监听器,当拖拽操作开始时触发。
    • 在这个例子中,当拖拽操作开始时,将 drag 变量设置为 true。这可以用于在拖拽开始时触发一些行为,比如改变样式或显示一些提示。
  4. @end="drag=false":
    • @end 是一个事件监听器,当拖拽操作结束时触发。
    • 在这个例子中,当拖拽操作结束时,将 drag 变量设置为 false。这可以用于在拖拽结束时触发一些行为,比如恢复样式或隐藏一些提示。

快速入门示例

javascript 复制代码
<template>
  <div id="app">
    <div class="list">
      <draggable v-model="list1" group="tasks" itemKey="id">
        <template #item="{ element }">
          <div class="task">{{ element.text }}</div>
        </template>
      </draggable>
    </div>
    <div class="list">
      <draggable v-model="list2" group="tasks" itemKey="id">
        <template #item="{ element }">
          <div class="task">{{ element.text }}</div>
        </template>
      </draggable>
    </div>
    <div class="list">
      <div class="ml1em">
        <span>[</span>
        <div v-for="item in list1">
          <div class="item ml2em">
            <span>{</span>
            <div class="attribute ml2em" v-for="(value, key) in item">
              <span>"{{ key }}":</span>
              <span class="value ml1em">"{{ value }}"</span>
              <span>,</span>
            </div>
            <span>},</span>
          </div>
        </div>
        <span>]</span>
      </div>
    </div>
    <div class="list">
      <div class="ml1em">
        <span>[</span>
        <div v-for="item in list2">
          <div class="item ml2em">
            <span>{</span>
            <div class="attribute ml2em" v-for="(value, key) in item">
              <span>"{{ key }}":</span>
              <span class="value ml1em">"{{ value }}"</span>
              <span>,</span>
            </div>
            <span>},</span>
          </div>
        </div>
        <span>]</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import draggable from "vuedraggable";
const list1 = ref([
  { id: 1, text: "Vue" },
  { id: 2, text: "React" },
  { id: 3, text: "Angular" },
]);
const list2 = ref([
  { id: 4, text: "ref" },
  { id: 5, text: "reactive" },
  { id: 6, text: "watch" },
  { id: 6, text: "watchEffect" },
  { id: 6, text: "computed" },
]);
</script>

<style scoped>
body {
  font-family: Arial, sans-serif;
  background-color: #f4f4f4;
  margin: 0;
  padding: 0;
}

#app {
  max-width: 1240px;
  margin: 50px auto;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  display: flex;
  margin-top: 20px;
}

.list {
  width: calc(25% - 20px);
  margin: 10px;
  color: darkblue;
  background-color: lightcoral;
}

.ml2em {
  margin-left: 2em;
}

.ml1em {
  margin-left: 1em;
}

.task {
  padding: 15px;
  margin: 5px 0;
  background-color: #42b983;
  color: white;
  border-radius: 4px;
  cursor: move;
  transition: background-color 0.3s, transform 0.3s;
}

.task:hover {
  background-color: #369870;
  transform: scale(1.02);
}
</style>


可以看到 draggable 组件使用的是 v-model 进行双向数据绑定,因此拖拽数据项时,对应的列表数据也会发生改变。

14. 拖动组件

14.1 拖动画布中组件

(1)下载拖拽依赖

javascript 复制代码
pnpm i vuedraggable@next

(2)画布中使用拖拽组件。views/EditorView/Center.vue:

javascript 复制代码
<template>
  <div ref="centerContainer" class="center-container">
    <draggable v-model="store.coms" item-key="index" @start="dragstart">
      <template #item="{ element, index }">
        <div
          class="content mb-10 relative"
          :class="{
            active: store.currentComponentIndex === index,
          }"
          @click="clickHandle(index)"
        >
          <component :is="element.type" :status="element.status" :serialNum="1" />
        </div>
      </template>
    </draggable>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 事件总监
import EventBus from '@/utils/eventBus';
import draggable from 'vuedraggable';

const centerContainer = ref<HTMLElement | null>(null);

const scrollToBottom = () => {
  nextTick(() => {
    const container = centerContainer.value; // 获取容器的dom元素
    if (container) {
      window.scrollTo({
        top: container.scrollHeight,
        behavior: 'smooth',
      });
    }
  });
};
// 通过事件总线提供滚动方法给外部调用
EventBus.on('scrollToBottom', scrollToBottom);

const clickHandle = (index: number) => {
  if (store.currentComponentIndex === index) {
    store.setCurrentComponentIndex(-1);
  } else {
    store.setCurrentComponentIndex(index);
  }
};
const dragstart = () => {
  store.setCurrentComponentIndex(-1);
};
</script>

<style scoped>
.center-container {
  width: 50%;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  margin: 70px auto;
  padding: 20px;
  background: var(--white);
  position: relative;

  .content {
    cursor: pointer;
    padding: 10px;
    background-color: var(--white);
    border-radius: var(--border-radius-sm);

    &:hover {
      transform: scale(1.01);
      transition: 0.5s;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
  }
}

.active {
  transform: scale(1.01);
  transition: 0.5s;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

.delete-btn {
  right: -5px;
  top: -10px;
}
</style>

14.2 绘制大纲组件,并实现拖拽

views/EditorView/LeftSide/Outline.vue:

javascript 复制代码
<template>
  <div v-if="store.surveyCount">
    <draggable v-model="store.coms" item-key="index" @start="dragstart">
      <template #item="{ element, index }">
        <div
          class="mb-10"
          @click="clickHandle(index)"
          :class="{
            active: store.currentComponentIndex === index,
          }"
        >
          <div class="item">
            1. {{
              element.status.title.status.length > 10
              ? element.status.title.status.substring(0, 10) + '...'
              : element.status.title.status

               }}
          </div>
        </div>
      </template>
    </draggable>
  </div>
  <div v-else class="tip flex align-items-center justify-content-center">请添加题目</div>
</template>

<script setup>
import draggable from 'vuedraggable';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
const dragstart = () => {
  store.setCurrentComponentIndex(-1);
};
const clickHandle = (index: number) => {
  if (store.currentComponentIndex === index) {
    store.setCurrentComponentIndex(-1);
  } else {
    store.setCurrentComponentIndex(index);
  }
};
</script>

<style lang="scss" scoped>
.item {
  /* outline: 1px solid black; */
  color: var(--font-color-light);
  font-size: var(--font-size-base);
  padding: 10px;
  cursor: pointer;
}
.tip {
  height: calc(100% - 50px);
}
.active {
  transform: scale(1.04);
  transition: 0.5s;
  background-color: var(--border-color);
  border-radius: var(--border-radius-lg);
}
</style>

14.3 解决计算序号问题

注意,至于题目类型的业务组件才会有序号,备注之类的非题目类型是没有序号,并且不应该显示在提纲中的。

(1)创建组合式函数 utils/hooks.ts:

javascript 复制代码
import { computed } from 'vue';
import type { Status } from '@/types';
import { isSurveyComName } from '@/types';
// 返回问卷题目序号的数组
export function useSurveyNo(coms: Status[]) {
  return computed(() => {
    let questionNumber = 1;
    return coms.map((com) => {
      // 需要判断当前这个组件是不是问卷题目
      if(isSurveyComName(com.name)){
        return questionNumber++
      }
      return null;
    })
  })
}

(2)画布中引入 useSurveyNo。views/EditorView/Center.vue 关键代码:

javascript 复制代码
<template>
  <div ref="centerContainer" class="center-container">
    <draggable v-model="store.coms" item-key="index" @start="dragstart">
      <template #item="{ element, index }">
        <div
          class="content mb-10 relative"
          :class="{
            active: store.currentComponentIndex === index,
          }"
          @click="clickHandle(index)"
        >
          <component :is="element.type" :status="element.status" :serialNum="serialNum[index]" />
        </div>
      </template>
    </draggable>
  </div>
</template>

<script setup lang="ts">
// 其他代码省略
import { nextTick, ref, computed } from 'vue';
// 组合式函数
import { useSurveyNo } from '@/utils/hooks';

const serialNum = computed(() => useSurveyNo(store.coms).value)
</script>

提纲组件同理,只是非题目组件部分需要隐藏。

14.4 点击提纲,画布对应组件滚动居中

(1)添加新的事件总线类型。 types/eventBus.ts:

javascript 复制代码
export type EventBus = {
  scrollToBottom: void;
  scrollToCenter: number;
};

(2)点击提纲题目时触发。views/EditorView/LeftSide/Outline.vue:

javascript 复制代码
<template>
  <div v-if="store.surveyCount">
    <draggable v-model="store.coms" item-key="index" @start="dragstart">
      <template #item="{ element, index }">
        <div
          class="mb-10"
          v-show="isSurveyComName(element.name)"
          @click="clickHandle(index)"
          :class="{
            active: store.currentComponentIndex === index,
          }"
        >
          <div class="item">
            {{ serialNum[index] }}.
            {{
              element.status.title.status.length > 10
                ? element.status.title.status.substring(0, 10) + '...'
                : element.status.title.status
            }}
          </div>
        </div>
      </template>
    </draggable>
  </div>
  <div v-else class="tip flex align-items-center justify-content-center">请添加题目</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
// 拖动组件
import draggable from 'vuedraggable';
import { isSurveyComName } from '@/types';
// 事件总线
import EventBus from '@/utils/eventBus';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 组合式函数
import { useSurveyNo } from '@/utils/hooks';
// 获取题目编号
const serialNum = computed(() => useSurveyNo(store.coms).value);

const dragstart = () => {
  store.setCurrentComponentIndex(-1);
};

const clickHandle = (index: number) => {
  if (store.currentComponentIndex === index) {
    store.setCurrentComponentIndex(-1);
  } else {
    store.setCurrentComponentIndex(index);
    EventBus.emit('scrollToCenter', index);
  }
};
</script>

<style scoped>
.item {
  /* outline: 1px solid black; */
  color: var(--font-color-light);
  font-size: var(--font-size-base);
  padding: 10px;
  cursor: pointer;
}
.tip {
  height: calc(100% - 50px);
}
.active {
  transform: scale(1.04);
  transition: 0.5s;
  background-color: var(--border-color);
  border-radius: var(--border-radius-lg);
}
</style>

(3)画布接收对应事件并定义。views/EditorView/Center.vue:

javascript 复制代码
<template>
  <div ref="centerContainer" class="center-container">
    <draggable v-model="store.coms" item-key="index" @start="dragstart">
      <template #item="{ element, index }">
        <div
          class="content mb-10 relative"
          :class="{
            active: store.currentComponentIndex === index,
          }"
          @click="clickHandle(index)"
          :key="element.id"
          :ref="(el) => (componentsRefs[index] = el)"
        >
          <component :is="element.type" :status="element.status" :serialNum="serialNum[index]" />
        </div>
      </template>
    </draggable>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref, computed, type ComponentPublicInstance } from 'vue';
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 事件总监
import EventBus from '@/utils/eventBus';
import draggable from 'vuedraggable';
// 组合式函数
import { useSurveyNo } from '@/utils/hooks';

const serialNum = computed(() => useSurveyNo(store.coms).value)

const centerContainer = ref<HTMLElement | null>(null);
const componentsRefs = ref<(Element | ComponentPublicInstance | null)[]>([]);

const scrollToBottom = () => {
  nextTick(() => {
    const container = centerContainer.value; // 获取容器的dom元素
    if (container) {
      window.scrollTo({
        top: container.scrollHeight,
        behavior: 'smooth',
      });
    }
  });
};
const scrollToCenter = (index: number) => {
  nextTick(() => {
    const element = componentsRefs.value[index]; // 获取当前题目的dom元素
    // 判断当前元素是否是HTMLElement
    if (element instanceof HTMLElement) {
      element.scrollIntoView({
        behavior: 'smooth',
        block: 'center',
      });
    }
  });
};

// 通过事件总线提供滚动方法给外部调用
EventBus.on('scrollToBottom', scrollToBottom);
EventBus.on('scrollToCenter', scrollToCenter);

const clickHandle = (index: number) => {
  if (store.currentComponentIndex === index) {
    store.setCurrentComponentIndex(-1);
  } else {
    store.setCurrentComponentIndex(index);
  }
};
const dragstart = () => {
  store.setCurrentComponentIndex(-1);
};
</script>

<style scoped>
.center-container {
  width: 50%;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  margin: 70px auto;
  padding: 20px;
  background: var(--white);
  position: relative;

  .content {
    cursor: pointer;
    padding: 10px;
    background-color: var(--white);
    border-radius: var(--border-radius-sm);

    &:hover {
      transform: scale(1.01);
      transition: 0.5s;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
  }
}

.active {
  transform: scale(1.01);
  transition: 0.5s;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

.delete-btn {
  right: -5px;
  top: -10px;
}
</style>

14.5 添加删除功能

(1)编辑器数据仓库 actions 添加删除方法。stores/useEditor.ts 关键代码:

javascript 复制代码
removeCom(index: number) {
  // 删除的时候要看删除的是不是问卷题目
  if (isSurveyComName(this.coms[index].name)) {
    this.surveyCount--;
  }
  this.coms.splice(index, 1);
},

(2)画布添加删除按钮。views/EditorView/Center.vue:

javascript 复制代码
<template>
  <div ref="centerContainer" class="center-container">
    <draggable v-model="store.coms" item-key="index" @start="dragstart">
      <template #item="{ element, index }">
        <div
          class="content mb-10 relative"
          :class="{
            active: store.currentComponentIndex === index,
          }"
          @click="clickHandle(index)"
          :key="element.id"
          :ref="(el) => (componentsRefs[index] = el)"
        >
          <component :is="element.type" :status="element.status" :serialNum="serialNum[index]" />
          <!-- 删除按钮 -->
          <div class="absolute delete-btn" v-show="store.currentComponentIndex === index">
            <el-button
              type="danger"
              class="ml-10"
              size="small"
              :icon="Close"
              circle
              @click.stop="removeCom(index)"
            />
          </div>
        </div>
      </template>
    </draggable>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref, computed, type ComponentPublicInstance } from 'vue';
import { useEditorStore } from '@/stores/useEditor';
import { Close } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
// 拖动组件
import draggable from 'vuedraggable';
const store = useEditorStore();
// 事件总监
import EventBus from '@/utils/eventBus';
// 组合式函数
import { useSurveyNo } from '@/utils/hooks';

// 获取题目编号
const serialNum = computed(() => useSurveyNo(store.coms).value);

const centerContainer = ref<HTMLElement | null>(null);
const componentsRefs = ref<(Element | ComponentPublicInstance | null)[]>([]);

const scrollToBottom = () => {
  nextTick(() => {
    const container = centerContainer.value; // 获取容器的dom元素
    if (container) {
      window.scrollTo({
        top: container.scrollHeight,
        behavior: 'smooth',
      });
    }
  });
};
const scrollToCenter = (index: number) => {
  nextTick(() => {
    const element = componentsRefs.value[index]; // 获取当前题目的dom元素
    // 判断当前元素是否是HTMLElement
    if (element instanceof HTMLElement) {
      element.scrollIntoView({
        behavior: 'smooth',
        block: 'center',
      });
    }
  });
};

// 通过事件总线提供滚动方法给外部调用
EventBus.on('scrollToBottom', scrollToBottom);
EventBus.on('scrollToCenter', scrollToCenter);

const clickHandle = (index: number) => {
  if (store.currentComponentIndex === index) {
    store.setCurrentComponentIndex(-1);
  } else {
    store.setCurrentComponentIndex(index);
  }
};

const dragstart = () => {
  store.setCurrentComponentIndex(-1);
};
// 删除选中的组件
const removeCom = (index: number) => {
  ElMessageBox.confirm('确定删除该组件吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning',
  })
    .then(() => {
      store.removeCom(index);
      store.setCurrentComponentIndex(-1);
      ElMessage.success('删除成功');
    })
    .catch(() => {
      ElMessage.info('已取消删除');
    });
};
</script>

<style scoped>
.center-container {
  width: 50%;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-md);
  margin: 70px auto;
  padding: 20px;
  background: var(--white);
  position: relative;
  .content {
    cursor: pointer;
    padding: 10px;
    background-color: var(--white);
    border-radius: var(--border-radius-sm);
    &:hover {
      transform: scale(1.01);
      transition: 0.5s;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
  }
}
.active {
  transform: scale(1.01);
  transition: 0.5s;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.delete-btn {
  right: -5px;
  top: -10px;
}
</style>

14.6 修复警告

chunk-FTY4BB2I.js?v=00923d30:2476 [Vue warn]: Invalid prop: type check

failed for prop "serialNum". Expected Number with value 0, got Null

备注组件不需要 serialNum 这个 prop

components/SurveyComs/Materials/NoteComs/TextNote.vue 关键代码:

javascript 复制代码
const props = defineProps<{
  serialNum: number;
  status: TypeStatus;
}>();

修改为

javascript 复制代码
const props = defineProps<{
  status: TypeStatus;
}>();

15. 存储问卷

按道理来说,应该使用服务器进行存储。但是我们这边为了讲解新知识,使用IndexedDB(浏览器内置的本地存储数据库,可以存储大量结构化数据,并且支持事务和索引查询)来进行存储。

15.1 dexie 简介(IndexedDB API 的轻量级 JS 库)

(1)简介。

npm 地址:https://www.npmjs.com/package/dexie

dexie.js 是一个封装了 IndexedDB API 的轻量级 JS 库,提供了简化且强大的数据库操作方式。

Dexie 主要特点和功能:

  1. 简洁易用的 API:Dexie.js 提供了一个更高层次的 API,使得数据库操作更加直观和简洁。你可以轻松地进行增删改查等操作。
  2. 事务支持:支持事务管理,确保多个操作要么全部成功,要么全部失败,从而保证数据的一致性。
  3. 异步操作:使用 Promise 进行异步操作,避免回调地狱,使代码更加清晰易读。
  4. 丰富的查询能力:提供了丰富的查询方法,支持复杂的查询条件和排序操作。
  5. 兼容性好:兼容大多数现代浏览器,包括 Chrome、Firefox、Safari 等。
  6. TypeScript 支持:Dexie.js 是用 TypeScript 编写的,提供了完善的类型定义,方便在 TypeScript 项目中使用。

(2)使用示例

js 复制代码
import Dexie from 'dexie';

// 创建数据库实例
const db = new Dexie('MyDatabase');

// 定义数据库的表结构
db.version(1).stores({
  friends: '++id,name,age'
});

// 打开数据库
db.open().catch((error) => {
  console.error("Failed to open db:", error);
});

// 添加数据
db.friends.add({name: 'John', age: 30}).then(() => {
  return db.friends.add({name: 'Doe', age: 25});
}).then(() => {
  // 查询数据
  return db.friends.where('age').above(25).toArray();
}).then((friends) => {
  console.log("Friends older than 25:", friends);
}).catch((error) => {
  console.error("Error:", error);
});

15.2 使用 dexie 定义和操作 IndexedDB

(1)下载依赖

javascript 复制代码
pnpm i dexie

(2)添加问卷表的数据类型文件。创建 types\db.ts

javascript 复制代码
import type { Status } from './common'
// 表的类型
export interface SurveyDBData {
  createDate: number;
  updateDate: number;
  title: string;
  surveyCount: number;
  coms: Status[];
}

在 types\index.ts 中引入:

javascript 复制代码
export * from './editProps';
export * from './common';
export * from './store';
export * from './eventBus';
export * from './db';

(3)定义数据库及表的结构。创建 db\db.ts:

javascript 复制代码
// 负责定义数据库以及表的结构
import Dexie, { type Table } from 'dexie';
import type { SurveyDBData } from '@/types';

class SurveyDataBase extends Dexie {
  // 定义了一个属性 survey,后面是该属性的类型
  // 该类型表示表的每一条记录是 SurveyDBData 类型,主键是 number 类型
  // survey后面的!叫做非空断言,表示 survey 是非空的
  surveys!: Table<SurveyDBData, number>;

  constructor() {
    super('SurveyDataBase'); // 数据库的名称
    this.version(1).stores({
      surveys: '++id, createDate, updateDate, title, surveyCount, coms',
    });
  }
}

const db = new SurveyDataBase();

export { db };

(4)添加数据库操作方法。创建 db/operation.ts:

javascript 复制代码
// 该文件提供具体的数据库操作方法的支持

import { db } from './db';
import type { SurveyDBData } from '@/types';

// 保存数据
export async function saveSurvey(data: SurveyDBData) {
  return await db.surveys.add(data);
}

// 查询所有数据
export async function getAllSurveys() {
  return await db.surveys.toArray();
}

// 根据 id 查询某一条数据
export async function getSurveyById(id: number) {
  return await db.surveys.get(id);
}

// 根据 id 删除某一条数据
export async function deleteSurveyById(id: number) {
  return await db.surveys.delete(id);
}

// 根据 id 更新某一条数据
export async function updateSurveyById(id: number, data: Partial<SurveyDBData>) {
  return await db.surveys.update(id, data);
}

15.3 重置问卷

(1)在编辑器数据仓库初始化数据,并添加重置方法。stores/useEditor.ts:

javascript 复制代码
// 该仓库用于存储画布的状态

import { defineStore } from 'pinia';
import type { Status } from '@/types';
import { isSurveyComName } from '@/types';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setCurrentStatus,
  setPicLinkByIndex,
} from './actions';
import { v4 as uuidv4 } from 'uuid';
import type { TypeStatus } from '@/types';
import { markRaw } from 'vue';
// 编辑器
import TextTypeEditor from '@/components/SurveyComs/EditItems/TextTypeEditor.vue';
import TitleEditor from '@/components/SurveyComs/EditItems/TitleEditor.vue';
import DescEditor from '@/components/SurveyComs/EditItems/DescEditor.vue';
import PositionEditor from '@/components/SurveyComs/EditItems/PositionEditor.vue';
import SizeEditor from '@/components/SurveyComs/EditItems/SizeEditor.vue';
import WeightEditor from '@/components/SurveyComs/EditItems/WeightEditor.vue';
import ItalicEditor from '@/components/SurveyComs/EditItems/ItalicEditor.vue';
import ColorEditor from '@/components/SurveyComs/EditItems/ColorEditor.vue';
import textNoteDefaultStatus from '@/configs/defaultStatus/TextNote';

// 仓库的初始化状态
const initStore = () => [
  Object.assign({}, textNoteDefaultStatus(), {
    status: <TypeStatus>{
      type: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['标题', '段落'],
        isShow: true,
        editCom: markRaw(TextTypeEditor),
        name: 'text-type-editor',
      },
      title: {
        id: uuidv4(),
        status: '问卷标题',
        isShow: true,
        editCom: markRaw(TitleEditor),
        name: 'title-editor',
      },
      desc: {
        id: uuidv4(),
        status: '默认描述内容',
        isShow: false,
        editCom: DescEditor,
        name: 'desc-editor',
      },
      position: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['左对齐', '居中对齐'],
        isShow: false,
        editCom: markRaw(PositionEditor),
        name: 'position-editor',
      },
      titleSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['26', '24', '22'],
        isShow: true,
        editCom: markRaw(SizeEditor),
        name: 'size-editor',
      },
      descSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['16', '14', '12'],
        isShow: false,
        editCom: markRaw(SizeEditor),
        name: 'size-editor',
      },
      titleWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        editCom: markRaw(WeightEditor),
        name: 'weight-editor',
      },
      descWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: false,
        editCom: markRaw(WeightEditor),
        name: 'weight-editor',
      },
      titleItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        editCom: markRaw(ItalicEditor),
        name: 'italic-editor',
      },
      descItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: false,
        editCom: markRaw(ItalicEditor),
        name: 'italic-editor',
      },
      titleColor: {
        id: uuidv4(),
        status: '#000',
        isShow: true,
        editCom: markRaw(ColorEditor),
        name: 'color-editor',
      },
      descColor: {
        id: uuidv4(),
        status: '#909399',
        isShow: false,
        editCom: markRaw(ColorEditor),
        name: 'color-editor',
      },
    },
  }),
  Object.assign({}, textNoteDefaultStatus(), {
    status: <TypeStatus>{
      type: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['标题', '段落'],
        isShow: true,
        editCom: markRaw(TextTypeEditor),
        name: 'text-type-editor',
      },
      title: {
        id: uuidv4(),
        status: '默认标题内容',
        isShow: false,
        editCom: markRaw(TitleEditor),
        name: 'title-editor',
      },
      desc: {
        id: uuidv4(),
        status:
          '为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,我们非常重视每位用户的宝贵意见,期待您的参与!现在我们就马上开始吧!',
        isShow: true,
        editCom: markRaw(DescEditor),
        name: 'desc-editor',
      },
      position: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['左对齐', '居中对齐'],
        isShow: true,
        editCom: markRaw(PositionEditor),
        name: 'position-editor',
      },
      titleSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['26', '24', '22'],
        isShow: false,
        editCom: markRaw(SizeEditor),
        name: 'size-editor',
      },
      descSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['16', '14', '12'],
        isShow: true,
        editCom: markRaw(SizeEditor),
        name: 'size-editor',
      },
      titleWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: false,
        editCom: markRaw(WeightEditor),
        name: 'weight-editor',
      },
      descWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        editCom: markRaw(WeightEditor),
        name: 'weight-editor',
      },
      titleItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: false,
        editCom: markRaw(ItalicEditor),
        name: 'italic-editor',
      },
      descItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        editCom: markRaw(ItalicEditor),
        name: 'italic-editor',
      },
      titleColor: {
        id: uuidv4(),
        status: '#000',
        isShow: false,
        editCom: markRaw(ColorEditor),
        name: 'color-editor',
      },
      descColor: {
        id: uuidv4(),
        status: '#909399',
        isShow: true,
        editCom: markRaw(ColorEditor),
        name: 'color-editor',
      },
    },
  }),
];

export const useEditorStore = defineStore('editor', {
  state: () => ({
    currentComponentIndex: -1, // 当前选中的组件索引,一开始都没有选中,所以是-1
    surveyCount: 0, // 问卷题目的数量
    coms: initStore() as Status[], // 问卷题目的数组
  }),
  actions: {
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setCurrentStatus,
    setPicLinkByIndex,
    addCom(newCom: Status) {
      this.coms.push(newCom);
      this.currentComponentIndex = -1;
      if (isSurveyComName(newCom.name)) this.surveyCount++;
    },
    setCurrentComponentIndex(index: number) {
      this.currentComponentIndex = index;
    },
    removeCom(index: number) {
      // 删除的时候要看删除的是不是问卷题目
      if (isSurveyComName(this.coms[index].name)) {
        this.surveyCount--;
      }
      this.coms.splice(index, 1);
    },
    resetComs() {
      this.coms = initStore() as Status[];
      this.surveyCount = 0;
      this.currentComponentIndex = -1;
    },
  },
});

(2)在头部组件新增重置问卷按钮。components/Common/Header.vue:

javascript 复制代码
<template>
  <div>
    <div class="container flex self-start align-items-center border-box">
      <!-- 分为三个部分 -->
      <div class="left flex justify-content-center align-items-center">
        <el-button :icon="ArrowLeft" circle size="small" @click="goHome" />
      </div>
      <div class="center flex align-items-center space-between pl-15 pr-15">
        <div v-if="isEditor">
          <div>
            <el-button type="danger"  size="small" @click="reset()">重置问卷</el-button>
            <el-button type="success" size="small">保存问卷</el-button>
          </div>
        </div>
      </div>
      <div class="right flex justify-content-center align-items-center">
        <el-avatar :size="30" :src="avatar" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ArrowLeft } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useRouter } from 'vue-router';
const router = useRouter();
import { ref } from 'vue';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();

defineProps({
  isEditor: {
    type: Boolean,
    default: true
  }
})

const goHome = () => {
  localStorage.setItem('activeView', 'home');
  router.push('/');
};

const avatar = ref('https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif');

// 重置问卷
const reset = () => {
  ElMessageBox.confirm('确定要重置问卷吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    store.resetComs();
    ElMessage.success('重置成功');
  }).catch(() => {
    ElMessage.info('已取消重置');
  })
}
</script>

<style scoped lang="scss">
.container {
  width: 100%;
  height: 50px;
  border-bottom: 1px solid var(--border-color);

  .left {
    width: 60px;
    height: 100%;
  }

  .center {
    flex: 1;
    height: 100%;
    border-left: 1px solid var(--border-color);
    border-right: 1px solid var(--border-color);
  }

  .right {
    width: 80px;
    height: 100%;
  }
}
</style>

(3)在编辑器页面设置 isEditor 属性为 true。views/EditorView/Index.vue 关键代码:

javascript 复制代码
<Header :is-editor="true" />

(4)在业务视图页面设置 isEditor 属性为 false。views/MaterialsView/Index.vue 关键代码:

javascript 复制代码
<Header :is-editor="false"/>

15.4 保存问卷

(1)编辑器数据仓库添加保存问卷方法。stores\useEditor.ts 关键代码:

javascript 复制代码
// indexedDB数据库操作方法
import { saveSurvey } from '@/db/operation';

// 保存问卷数据
saveComs(data: SurveyDBData) {
  return saveSurvey(data);
},

(2)头部组件添加保存问卷方法。components/Common/Header.vue 关键代码:

html 复制代码
<el-button type="success" size="small" @click="saveSurvey()">保存问卷</el-button>
javascript 复制代码
// 保存问卷
const saveSurvey = () => {
  ElMessageBox.prompt('请输入问卷的标题', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消'
  }).then(({ value }) => {
    const surveyToSave = {
      createDate: new Date().getTime(),
      title: value,
      updateDate: new Date().getTime(),
      surveyCount: store.surveyCount,
      coms: JSON.parse(JSON.stringify(store.coms)),
    };
    store
      .saveComs(surveyToSave)
      .then(() => {
        console.log(store.coms);
        ElMessage.success('问卷已保存');
      })
      .catch(() => {
        ElMessage.error('问卷保存失败');
      });
  }).catch(() => {
    ElMessage.info('已取消保存');
  })
}


15.5 显示问卷列表

(1)添加处理日期格式的辅助方法。 utils/index.ts 关键代码:

javascript 复制代码
// 处理日期格式的辅助方法
export function formatDate(
  row: SurveyDBData,
  column: TableColumnCtx<SurveyDBData>,
  cellValue: number,
) {
  const options: Intl.DateTimeFormatOptions = {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
  };
  return new Intl.DateTimeFormat('zh-CN', options).format(new Date(cellValue));
}

(2)主页面请求IndexedDB 数据。views/HomeView.vue:

javascript 复制代码
<template>
  <div class="pt-20 pb-20 pl-20 pr-20">
    <h1 class="font-weight-100 text-center">问卷调查系统</h1>
    <!--按钮组 -->
    <div class="mb-15">
      <el-button type="primary" :icon="Plus" @click="goToEditor()">创建问卷</el-button>
      <el-button type="primary" :icon="Compass" @click="goToComMarket()">组件市场</el-button>
    </div>
    <!-- 数据表格 -->
    <el-table :data="tableData" style="width: 100%" border>
      <el-table-column
        fixed
        prop="createDate"
        label="创建日期"
        width="160"
        :formatter="formatDate"
      />
      <el-table-column prop="title" label="问卷标题" />
      <el-table-column prop="surveyCount" label="题目数" width="150" align="center" />
      <el-table-column
        prop="updateDate"
        label="最近更新日期"
        width="160"
        align="center"
        :formatter="formatDate"
      />
      <el-table-column fixed="right" label="操作" width="300" align="center">
        <template #default="scope">
          <el-button link type="primary" size="small">查看问卷</el-button>
          <el-button link type="primary" size="small">编辑</el-button>
          <el-button link type="primary" size="small">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { Plus, Compass } from '@element-plus/icons-vue'
import { ref } from 'vue'
// 路由
import { useRouter } from 'vue-router'
const router = useRouter()
// 类型
import type { SurveyDBData } from '@/types';
// 工具方法
import { formatDate } from '@/utils';

import { getAllSurveys } from '@/db/operation';

const tableData = ref<SurveyDBData[]>([])

function getData() {
  getAllSurveys().then(res => {
    tableData.value = res;
  })
}

getData()

const goToEditor = () => {
  localStorage.setItem('activeView', 'editor');
  router.push('/editor');
};

const goToComMarket = () => {
  localStorage.setItem('activeView', 'materials');
  router.push('/materials');
};

</script>

<style lang="scss" scoped></style>

16. 预览问卷

16.1 创建预览问卷页面


(1)创建 views/Preview.vue:

javascript 复制代码
<template>
  <div>
    预览页面
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

(2)添加路由。router/index.ts 关键代码:

javascript 复制代码
{
  path: '/preview/:id(\\d+)',
  name: 'preview',
  component: () => import('@/views/Preview.vue'),
}

(3)添加返回问卷类型 SurveyDBReturnData。types/db.ts:

javascript 复制代码
import type { Status } from './common'
// 表的类型
export interface SurveyDBData {
  createDate: number;
  updateDate: number;
  title: string;
  surveyCount: number;
  coms: Status[];
}

export interface SurveyDBReturnData extends SurveyDBData {
  id: number;
}

(4)views/HomeView.vue 关键代码:

javascript 复制代码
<el-button link type="primary" size="small" @click="viewSurvey(scope.row)">查看问卷</el-button>
javascript 复制代码
// 类型
import type { SurveyDBData, SurveyDBReturnData } from '@/types';
javascript 复制代码
// 预览问卷
const viewSurvey = (surveyInfo: SurveyDBReturnData) => {
  router.push({
    path: `/preview/${surveyInfo.id}`,
    state: {
      from: 'home'
    }
  });
}

这里之所以要创建 SurveyDBReturnData,是因为 SurveyDBData 并没有 id 属性,如果使用 SurveyDBData 标注 surveyInfo,使用 surveyInfo.id 时会有警告。

16.2 还原问卷数据

因为直接存储 coms: store.coms 会报错,IndexedDB 无法直接克隆。因此使用 coms: JSON.parse(JSON.stringify(store.coms)) 进行存储。但是这样也存在问题,就是会丢失组件原本的 rendersetup ,从而导致无法直接还原试卷。

不过,好在我们可以根据 name 找到程序中对应的组件进行还原。

(1)新增组件名和具体组件映射。创建 configs/componentMap.ts:

javascript 复制代码
// 该文件只做一件事,就是形成组件名和具体组件的一个映射关系
// 业务组件
import SingleSelect from "@/components/SurveyComs/Materials/SelectComs/SingleSelect.vue"
import SinglePicSelect from "@/components/SurveyComs/Materials/SelectComs/SinglePicSelect.vue"
import TextNote from "@/components/SurveyComs/Materials/NoteComs/TextNote.vue"
// 编辑组件
import TitleEditor from '@/components/SurveyComs/EditItems/TitleEditor.vue';
import DescEditor from '@/components/SurveyComs/EditItems/DescEditor.vue';
import PositionEditor from '@/components/SurveyComs/EditItems/PositionEditor.vue';
import SizeEditor from '@/components/SurveyComs/EditItems/SizeEditor.vue';
import WeightEditor from '@/components/SurveyComs/EditItems/WeightEditor.vue';
import ItalicEditor from '@/components/SurveyComs/EditItems/ItalicEditor.vue';
import ColorEditor from '@/components/SurveyComs/EditItems/ColorEditor.vue';
import TextTypeEditor from '@/components/SurveyComs/EditItems/TextTypeEditor.vue';
import OptionsEditor from '@/components/SurveyComs/EditItems/OptionsEditor.vue';
import PicOptionsEditor from '@/components/SurveyComs/EditItems/PicOptionsEditor.vue';
import { markRaw } from 'vue';
import type { ComponentMap } from '@/types'

export const componentMap: ComponentMap = {
  // 业务组件
  'single-select': markRaw(SingleSelect), // 单选组件
  'single-pic-select': markRaw(SinglePicSelect), // 单图单选组件
  'text-note': markRaw(TextNote), // 文本组件
  'personal-info-gender': markRaw(SingleSelect), // 个人信息-性别组件
  'personal-info-education': markRaw(SingleSelect), // 个人信息-学历组件
  // 编辑组件
  'title-editor': markRaw(TitleEditor),
  'desc-editor': markRaw(DescEditor),
  'position-editor': markRaw(PositionEditor),
  'size-editor': markRaw(SizeEditor),
  'weight-editor': markRaw(WeightEditor),
  'italic-editor': markRaw(ItalicEditor),
  'color-editor': markRaw(ColorEditor),
  'text-type-editor': markRaw(TextTypeEditor),
  'options-editor': markRaw(OptionsEditor),
  'pic-options-editor': markRaw(PicOptionsEditor),
};

(2)添加相关类型。store.ts 关键代码:

javascript 复制代码
import type { TextProps, OptionsProps, PicLink, Status, VueComType } from '@/types';
// 题目类型
export type SurveyComName =
  | 'single-select'
  | 'single-pic-select'
  | 'personal-info-gender'
  | 'personal-info-education';

// 业务组件类型(题目类型 + 非题目类型)
export type Material = SurveyComName | 'text-note';

// 编辑组件类型:集合了所有的编辑组件
export type EditComName =
  | 'title-editor'
  | 'desc-editor'
  | 'position-editor'
  | 'size-editor'
  | 'weight-editor'
  | 'italic-editor'
  | 'text-type-editor'
  | 'pic-options-editor'
  | 'options-editor'
  | 'color-editor';

// 所有的组件类型:业务组件类型 + 编辑组件类型
export type ComponentName = Material | EditComName;

export type ComponentMap = {
  [key in ComponentName]: VueComType;
};

(3)types/common.ts 修改 status 属性类型:

javascript 复制代码
import type { defineComponent } from 'vue';
import type { TextProps, Material, OptionsProps } from '@/types';
// import type { OptionsStatus, Material, TypeStatus } from '@/types';

// 导出 vue 组件类型
export type VueComType = ReturnType<typeof defineComponent>;

// 业务组件状态,也就是包含了 type、name、id、status 这些属性
export interface Status {
  type: VueComType;
  name: Material;
  id: string;
  // status: OptionsStatus | TypeStatus;
  status: {
    [key: string]: TextProps | OptionsProps;
  };
}

(4)utils/index.ts 新增还原组件状态方法。关键代码:

javascript 复制代码
export const restoreComponentStatus = (coms: Status[]) => {
  coms.forEach((com) => {
    // 业务组件还原
    com.type = componentMap[com.name]
    // 编辑组件还原
    for (const key in com.status) {
      const name = com.status[key].name as EditComName
      com.status[key].editCom = componentMap[name]
    }
  })
}

(5)HomeView 组件不使用懒加载,否则会导致部分组件无法初始化。

router/index.ts 关键代码:

javascript 复制代码
import HomeView from '@/views/HomeView.vue';
import { useMaterialStore } from '@/stores/useMaterial';
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      // component: () => import('@/views/HomeView.vue'),
      component: HomeView,
    },
    // 下面代码省略

(6)预览页面获取存储的问卷题目并进行还原。views/Preview.vue:

javascript 复制代码
<template>
  <div>
    预览页面
  </div>
</template>

<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
// const router = useRouter();
const route = useRoute();
import { getSurveyById } from '@/db/operation';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();

import { restoreComponentStatus } from '@/utils';

// 获取路由参数
const id = Number(route.params.id);
// 根据id 获取存储的问卷题目
if(id) {
  getSurveyById(id).then(res => {
    // console.log(res);
    if(res) {
      // 拿到数据后,组件部分需要重新还原
      restoreComponentStatus(res.coms)
      // 将还原的数据设置为仓库里面的 coms 即可。
      store.setStore(res)
    }
    console.log(res);
  })
}
</script>

<style scoped>

</style>

16.3 绘制预览页面

views/Preview.vue:

javascript 复制代码
<template>
  <div class="preview-container pb-40">
    <div class="center mc">
      <!-- 上面的按钮组 -->
      <div class="button-group flex space-between align-items-center">
        <!-- 左边按钮 -->
        <div class="flex space-between">
          <el-button type="danger" @click="gobackHandle">返回</el-button>
          <el-button type="success">生成在线问卷</el-button>
          <el-button type="warning">生成本地PDF</el-button>
        </div>
        <!-- 题目数量 -->
        <div class="mr-15">
          <el-text class="mx-1">题目数量:{{ store.surveyCount }}</el-text>
        </div>
      </div>
      <!-- 对应的问卷 -->
      <div class="content-group no-border">
        <div class="content mb-10" v-for="(com, index) in store.coms" :key="index">
          <component :is="com.type" :status="com.status" :serialNum="serialNum[index]" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
import { getSurveyById } from '@/db/operation';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 工具方法
import { restoreComponentStatus } from '@/utils';
// 自定义Hook
import { useSurveyNo } from '@/utils/hooks';
// 获取序号
const serialNum = computed(() => useSurveyNo(store.coms).value);
// 获取路由参数
const id = Number(route.params.id);
// 接下来应该根据拿到的 id 去获取存储的问卷题目
if (id) {
  getSurveyById(id).then((res) => {
    if (res) {
      // 拿到数据后,组件部分需要重新还原
      restoreComponentStatus(res.coms);
      // 还原完成之后,将还原的数据设置为仓库里面的 coms 即可
      store.setStore(res);
    }
    console.log(res);
  });
}
// 返回按钮对应逻辑
const gobackHandle = () => {
  const path = history.state.from;
  if (path === 'home') {
    // 说明是从首页进来的
    router.back();
  } else {
    // 说明是从编辑页面进来的
    router.push(`/editor/${id}/survey-type`);
  }
};
</script>

<style scoped lang="scss">
.preview-container {
  width: 100%;
  min-height: 100vh;
  background: url('@/assets/imgs/editor_background.png');
}
.center {
  width: 800px;
}
.button-group {
  width: 100%;
  height: 60px;
  top: 0;
  left: 0;
  background-color: var(--white);
  z-index: 100;
}
.content-group {
  padding: 20px;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-lg);
  background: var(--white);
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
</style>

17. 编辑和删除问卷

17.1 编辑问卷

(1)添加编辑页路由。router/index.ts 关键代码:

javascript 复制代码
{
      path: '/editor/:id(\\d+)?',
      name: 'editor',
      component: () => import('@/views/EditorView/Index.vue'),
      children: [
        {
          path: 'survey-type',
          name: 'survey-type',
          component: () => import('@/views/EditorView/LeftSide/SurveyType.vue'),
        },
        {
          path: 'outline',
          name: 'outline',
          component: () => import('@/views/EditorView/LeftSide/Outline.vue'),
        },
      ],
    },

(2)主页添加前往编辑页方法。views/HomeView.vue 关键代码:

javascript 复制代码
<el-button link type="primary" size="small" @click="editSurvey(scope.row)">编辑</el-button>
javascript 复制代码
// 编辑问卷
const editSurvey = (surveyInfo: SurveyDBReturnData) => {
  router.push({
    path: `/editor/${surveyInfo.id}/survey-type`,
  });
}

(3)根据 id 获取对应数据仓库数据。views/EditorView/Index.vue 关键代码:

javascript 复制代码
<script setup lang="ts">
import { computed } from 'vue';
import Header from '@/components/Common/Header.vue';
import LeftSide from '@/views/EditorView/LeftSide/Index.vue';
import Center from '@/views/EditorView/Center.vue';
import RightSide from '@/views/EditorView/RightSide.vue';
import { getSurveyById } from '@/db/operation';

import { restoreComponentStatus } from '@/utils';

// 路由
import { useRoute } from 'vue-router';
const route = useRoute();
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
store.resetComs()

const id = computed(() => route.params.id ? String(route.params.id) : '')
if(id.value) {
  getSurveyById(Number(id.value)).then((res) => {
    if(res) {
      restoreComponentStatus(res.coms)
      store.setStore(res)
    }
  })
}
</script>

(4)数据仓库添加更新问卷方法。stores/useEditor.ts 关键代码:

javascript 复制代码
// indexedDB数据库操作方法
import { saveSurvey, updateSurveyById } from '@/db/operation';
javascript 复制代码
// 更新问卷
    updateComs(id: number, data: Partial<SurveyDBData>) {
      return updateSurveyById(id, data)
    }

(5)头部组件添加更新相关按钮和逻辑。components/Common/Header.vue:

javascript 复制代码
<template>
  <div>
    <div class="container flex self-start align-items-center border-box">
      <!-- 分为三个部分 -->
      <div class="left flex justify-content-center align-items-center">
        <el-button :icon="ArrowLeft" circle size="small" @click="goHome" />
      </div>
      <div class="center flex align-items-center space-between pl-15 pr-15">
        <div v-if="isEditor">
          <div v-if="id">
            <el-button type="warning" size="small" @click="updateSurvey()">更新问卷</el-button>
          </div>
          <div v-else>
            <el-button type="danger" size="small" @click="reset()">重置问卷</el-button>
            <el-button type="success" size="small" @click="saveSurvey()">保存问卷</el-button>
          </div>
        </div>
        <div v-if="id">
          <el-button type="primary" size="small" @click="preview()">预览</el-button>
        </div>
      </div>
      <div class="right flex justify-content-center align-items-center">
        <el-avatar :size="30" :src="avatar" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ArrowLeft } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useRouter } from 'vue-router';
const router = useRouter();
import { ref } from 'vue';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();

const props = defineProps({
  isEditor: {
    type: Boolean,
    default: true
  },
  id: {
    type: String,
    default: ''
  }
})

const goHome = () => {
  localStorage.setItem('activeView', 'home');
  router.push('/');
};

const avatar = ref('https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif');

// 重置问卷
const reset = () => {
  ElMessageBox.confirm('确定要重置问卷吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    store.resetComs();
    ElMessage.success('重置成功');
  }).catch(() => {
    ElMessage.info('已取消重置');
  })
}

// 保存问卷
const saveSurvey = () => {
  ElMessageBox.prompt('请输入问卷的标题', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消'
  }).then(({ value }) => {
    const surveyToSave = {
      createDate: new Date().getTime(),
      title: value,
      updateDate: new Date().getTime(),
      surveyCount: store.surveyCount,
      coms: JSON.parse(JSON.stringify(store.coms)),
    };
    store
      .saveComs(surveyToSave)
      .then((id) => {
        router.push(`/editor/${id}/survey-type`);
        ElMessage.success('问卷已保存');
      })
      .catch(() => {
        ElMessage.error('问卷保存失败');
      });
  }).catch(() => {
    ElMessage.info('已取消保存');
  })
}

// 更新问卷
const updateSurvey = () => {
  store
    .updateComs(Number(props.id), {
      updateDate: new Date().getTime(),
      surveyCount: store.surveyCount,
      coms: JSON.parse(JSON.stringify(store.coms)),
    })
    .then(() => {
      ElMessage.success('问卷已更新');
    })
    .catch(() => {
      ElMessage.error('问卷更新失败');
    });
};

// 预览问卷
const preview = () => {
  ElMessageBox.confirm('预览会自动保存问卷,是否跳转预览', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消'
  }).then(() => {
    updateSurvey();
    router.push({
      path: `/preview/${props.id}`,
      state: {
        from: 'editor'
      }
    })
  }).catch(() => {
    ElMessage.info('已取消跳转');
  })
}
</script>

<style scoped lang="scss">
.container {
  width: 100%;
  height: 50px;
  border-bottom: 1px solid var(--border-color);

  .left {
    width: 60px;
    height: 100%;
  }

  .center {
    flex: 1;
    height: 100%;
    border-left: 1px solid var(--border-color);
    border-right: 1px solid var(--border-color);
  }

  .right {
    width: 80px;
    height: 100%;
  }
}
</style>

17.2 删除问卷

views/HomeView.vue 关键代码:

javascript 复制代码
<el-button link type="primary" size="small" @click="deleteSurvey(scope.row)">删除</el-button>
javascript 复制代码
import { getAllSurveys, deleteSurveyById } from '@/db/operation';
import { ElMessageBox, ElMessage } from 'element-plus';
javascript 复制代码
// 删除问卷
const deleteSurvey = (surveyInfo: SurveyDBReturnData) => {
  ElMessageBox.confirm('确定删除该问卷吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    deleteSurveyById(surveyInfo.id).then(() => {
      ElMessage.success('删除成功');
      getData()
    })
  }).catch(() => {
    ElMessage.info('已取消删除');
  })
}

18. 生成PDF和在线问卷

18.1 生成PDF

(1)添加判断当前组件是否可打印方法。types/store.ts 关键代码:

javascript 复制代码
// 该数组记录适合生成PDF的题目类型
const PDFComs = [
  'single-select',
  'single-pic-select',
  'personal-info-gender',
  'personal-info-education',
  'text-note',
];

export function canUsedForPDF(value: string): boolean {
  return PDFComs.includes(value);
}

(2)预览页添加打印方法和样式。views/Preview.vue:

javascript 复制代码
<template>
  <div class="preview-container pb-40">
    <div class="center mc">
      <!-- 上面的按钮组 -->
      <div class="button-group flex space-between align-items-center">
        <!-- 左边按钮 -->
        <div class="flex space-between no-print">
          <el-button type="danger" @click="gobackHandle()">返回</el-button>
          <el-button type="success">生成在线问卷</el-button>
          <el-button type="warning" @click="genPDF()">生成本地PDF</el-button>
        </div>
        <!-- 题目数量 -->
        <div class="mr-15">
          <el-text class="mx-1">题目数量:{{ store.surveyCount }}</el-text>
        </div>
      </div>
      <!-- 对应的问卷 -->
      <div class="content-group no-border">
        <div class="content mb-10" v-for="(com, index) in store.coms" :key="index">
          <component :is="com.type" :status="com.status" :serialNum="serialNum[index]" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
import { getSurveyById } from '@/db/operation';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 工具方法
import { restoreComponentStatus } from '@/utils';
// 自定义Hook
import { useSurveyNo } from '@/utils/hooks';

import { canUsedForPDF } from '@/types';
import { ElMessage } from 'element-plus';

// 获取序号
const serialNum = computed(() => useSurveyNo(store.coms).value);
// 获取路由参数
const id = Number(route.params.id);
// 接下来应该根据拿到的 id 去获取存储的问卷题目
if (id) {
  getSurveyById(id).then((res) => {
    if (res) {
      // 拿到数据后,组件部分需要重新还原
      restoreComponentStatus(res.coms);
      // 还原完成之后,将还原的数据设置为仓库里面的 coms 即可
      store.setStore(res);
    }
    console.log(res);
  });
}
// 返回按钮对应逻辑
const gobackHandle = () => {
  const path = history.state.from;
  if (path === 'home') {
    // 说明是从首页进来的
    router.back();
  } else {
    // 说明是从编辑页面进来的
    router.push(`/editor/${id}/survey-type`);
  }
};

// 生成PDF
const genPDF = () => {
  // 1. 检查:当前问卷是否存在不适合生成PDF的业务组件
  const result = store.coms.every((item) => canUsedForPDF(item.name))
  if (!result) {
    ElMessage.warning('当前问卷中存在不支持生成PDF的业务组件,请检查后再试!');
    return;
  }
  // 2. 开始生成PDF
  // 注意:关于生成PDF,解决方案非常的多,可以前端来生成PDF,也可以服务器端来生成PDF
  // 无论是前端还是后端,解决方案都不止一种
  // 因为我们这里生成PDF的需求很简单,所以我们选择使用浏览器的接口来生成PDF
  window.print();
}
</script>

<style scoped lang="scss">
.preview-container {
  width: 100%;
  min-height: 100vh;
  background: url('@/assets/imgs/editor_background.png');
}
.center {
  width: 800px;
}
.button-group {
  width: 100%;
  height: 60px;
  top: 0;
  left: 0;
  background-color: var(--white);
  z-index: 100;
}
.content-group {
  padding: 20px;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-lg);
  background: var(--white);
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
// 这是一个媒体查询,表示在打印的时候,会应用 no-print 以及 no-border 类
@media print {
  .no-print {
    display: none;
  }
  .no-border {
    border: none;
    box-shadow: none;
  }
}
</style>

18.2 生成在线问卷

(1)服务器端添加接口,用于存储问卷、获取问卷内容、存储答案server\server.js:

javascript 复制代码
const express = require("express"); // 引入 express
const bodyParser = require("body-parser");
const multer = require("multer");
const path = require("path");
const fs = require("fs");

const app = express();

// 设置 body-parser 选项,增加请求体大小限制
app.use(bodyParser.json({ limit: "50mb" })); // 允许最大 50MB 的 JSON 请求体
app.use(bodyParser.urlencoded({ limit: "50mb", extended: true })); // 允许最大 50MB 的 URL 编码请求体

let quizzes = {}; // 存储问题
let answers = {}; // 存储答案

// 针对在线答题功能,提供3个新的接口
// 存储问卷
app.post("/api/saveQuiz", (req, res) => {
  const { id, quizData } = req.body;
  quizzes[id] = quizData;
  res.status(200).send({ message: "Quiz saved" });
});
// 根据id获取问卷内容
app.get("/api/getQuiz/:id", (req, res) => {
  // 本来正常的逻辑,这里应该根据前端传递过来的问卷 id,从数据库来获取问卷内容,然后返回给前端
  // 但是我们这是一个简化项目,没有数据库,使用的是 indexedDB 来存储的问卷数据
  // 因此有了saveQuiz这个接口,我们可以直接从内存中获取问卷数据
  const quizData = quizzes[req.params.id];
  res.status(200).send(quizData);
});
// 存储答案
app.post("/api/submitAnswers", (req, res) => {
  const { quizId, answers: userAnswers } = req.body;
  answers[quizId] = userAnswers;
  console.table(answers);
  res.status(200).send({ message: "Answers submitted" });
});

// 设置 multer 存储引擎
const storage = multer.diskStorage({
  // 上传的文件要存储到哪里
  destination: function (req, file, cb) {
    // 上传的文件夹路径,需要在项目根目录下创建 uploads 子文件夹
    const uploadDir = path.join(__dirname, "uploads");
    // 如果 uploads 子文件夹不存在,则创建它
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir);
    }
    // 上传的文件夹路径
    cb(null, "uploads");
  },
  // 上传的文件名字如何命名
  filename: function (req, file, cb) {
    // 给上传的文件一个唯一的后缀来保证不重名
    const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
    cb(
      null,
      file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)
    );
  },
});

const upload = multer({ storage: storage });

// 添加上传图片的路由接口
app.post("/api/upload", upload.single("image"), (req, res) => {
  try {
    res.status(200).send({
      message: "图片上传成功",
      imageUrl: `/uploads/${req.file.filename}`,
    });
  } catch (error) {
    res.status(500).send({ message: "图片上传失败" });
  }
});

// 提供静态资源服务
app.use("/uploads", express.static(path.join(__dirname, "uploads")));

app.listen(3001, () => {
  console.log("server is running at 3001");
});

(2)重启 server 项目:

javascript 复制代码
pnpm start

(3)创建答题页面。views/QuizView.vue:

javascript 复制代码
<template>
  <div v-if="quizData">
    <div class="quiz-container mc">
      <div class="mt-30 mb-20">题目数量:{{ quizData.surveyCount }}</div>
      <div class="content mb-10" v-for="(com, index) in quizData.coms" :key="index">
        <component
          :is="com.type"
          :status="com.status"
          :serialNum="serialNum[index]"
          @updateAnswer="updateAnswer(index, $event)"
        />
      </div>
      <div class="mt-20 mb-20 text-center">
        <el-button type="primary" @click="submitAnswers">提交答案</el-button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { Ref } from 'vue';
import { onMounted, ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { useRoute } from 'vue-router';
const route = useRoute();

import type { QuizData } from '@/types';

import { restoreComponentStatus } from '@/utils';
// 组合式函数
import { useSurveyNo } from '@/utils/hooks';
// 获取题目编号
const serialNum = computed(() => useSurveyNo(quizData.value?.coms).value);

const quizData = ref<QuizData>({
  coms: [],
  surveyCount: 0,
});

onMounted(async () => {
  const quizId = route.params.id;
  // 从服务器获取试卷内容
  const response = await fetch(`/api/getQuiz/${quizId}`);
  const data = await response.json();
  data.coms = JSON.parse(data.coms);
  restoreComponentStatus(data.coms);
  quizData.value = data;
});

// 用来存储要发送服务器的答案
const answers: Ref<{ [key: number]: string | number | Date }> = ref({});

const updateAnswer = (index: number, answer: string | number) => {
  // console.log(index, answer);
  const serial = serialNum.value[index];
  if (serial !== null) {
    // 说明是题目组件
    answers.value[serial] = answer;
  }
  console.log(answers.value);
};

const submitAnswers = async () => {
  const quizId = route.params.id;
  await fetch(`/api/submitAnswers`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      quizId,
      answers: answers.value,
    }),
  });
  ElMessage.success('提交成功!');
};
</script>

<style scoped lang="scss">
.quiz-container {
  width: 800px;
}
</style>

(4)添加答题路由。router/index.ts 关键代码:

javascript 复制代码
{
  path: '/quiz/:id',
  name: 'quiz',
  component: () => import('@/views/QuizView.vue'),
},

(5)预览页面添加生成在线问卷功能。views/Preview.vue:

javascript 复制代码
<template>
  <div class="preview-container pb-40">
    <div class="center mc">
      <!-- 上面的按钮组 -->
      <div class="button-group flex space-between align-items-center">
        <!-- 左边按钮 -->
        <div class="flex space-between no-print">
          <el-button type="danger" @click="gobackHandle">返回</el-button>
          <el-button type="success" @click="genQuiz">生成在线问卷</el-button>
          <el-button type="warning" @click="genPDF">生成本地PDF</el-button>
        </div>
        <!-- 题目数量 -->
        <div class="mr-15">
          <el-text class="mx-1">题目数量:{{ store.surveyCount }}</el-text>
        </div>
      </div>
      <!-- 对应的问卷 -->
      <div class="content-group no-border">
        <div class="content mb-10" v-for="(com, index) in store.coms" :key="index">
          <component :is="com.type" :status="com.status" :serialNum="serialNum[index]" />
        </div>
      </div>
    </div>
  </div>
  <el-dialog v-model="dialogVisible" title="在线问卷" width="500">
    分享链接: <a :href="quizLink" target="_blank">{{ quizLink }}</a>
    <template #footer>
      <div class="dialog-footer">
        <el-button type="primary" @click="copyLink">复制链接</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
import { getSurveyById } from '@/db/operation';
// 仓库
import { useEditorStore } from '@/stores/useEditor';
const store = useEditorStore();
// 工具方法
import { restoreComponentStatus } from '@/utils';
// 自定义Hook
import { useSurveyNo } from '@/utils/hooks';

import { canUsedForPDF } from '@/types';
import { ElMessage } from 'element-plus';
import { v4 as uuidv4 } from 'uuid';

// 控制弹出框是否显示
const dialogVisible = ref(false);
const quizLink = ref(''); // 存储生成的在线答题的链接

// 获取序号
const serialNum = computed(() => useSurveyNo(store.coms).value);
// 获取路由参数
const id = Number(route.params.id);
// 接下来应该根据拿到的 id 去获取存储的问卷题目
if (id) {
  getSurveyById(id).then((res) => {
    if (res) {
      // 拿到数据后,组件部分需要重新还原
      restoreComponentStatus(res.coms);
      // 还原完成之后,将还原的数据设置为仓库里面的 coms 即可
      store.setStore(res);
    }
  });
}
// 返回按钮对应逻辑
const gobackHandle = () => {
  const path = history.state.from;
  if (path === 'home') {
    // 说明是从首页进来的
    router.back();
  } else {
    // 说明是从编辑页面进来的
    router.push(`/editor/${id}/survey-type`);
  }
};

// 生成PDF
const genPDF = () => {
  // 1. 检查:检查当前的问卷是否存在不适合生成PDF的业务组件
  const result = store.coms.every((item) => canUsedForPDF(item.name));
  if (!result) {
    ElMessage.warning('当前问卷中存在不支持生成PDF的业务组件,请检查后再试!');
    return;
  }
  // 2. 开始生成PDF
  // 注意:关于生成PDF,解决方案非常的多,可以前端来生成PDF,也可以服务器端来生成PDF
  // 无论是前端还是后端,解决方案都不止一种
  // 因为我们这里生成PDF的需求很简单,所以我们选择使用浏览器的接口来生成PDF
  window.print();
};

// 生成在线问卷
const genQuiz = () => {
  // 1. 首先将问卷的数据传递到服务器端,服务器端存储到内存中
  const id = uuidv4();
  // 将问卷内容和id传递给服务器
  fetch('/api/saveQuiz', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      id,
      quizData: {
        coms: JSON.stringify(store.coms),
        surveyCount: store.surveyCount,
      },
    }),
  });
  // 2. 将弹出框显示出来
  quizLink.value = `${window.location.origin}/quiz/${id}`;
  dialogVisible.value = true;
};

// 复制在线答题的链接
const copyLink = () => {
  dialogVisible.value = false;
  navigator.clipboard.writeText(quizLink.value);
  ElMessage.success('在线答题的链接已复制');
};
</script>

<style scoped lang="scss">
.preview-container {
  width: 100%;
  min-height: 100vh;
  background: url('@/assets/imgs/editor_background.png');
}
.center {
  width: 800px;
}
.button-group {
  width: 100%;
  height: 60px;
  top: 0;
  left: 0;
  background-color: var(--white);
  z-index: 100;
}
.content-group {
  padding: 20px;
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius-lg);
  background: var(--white);
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
// 这是一个媒体查询,表示在打印的时候,会应用 no-print 以及 no-border 类
@media print {
  .no-print {
    display: none;
  }
  .no-border {
    border: none;
    box-shadow: none;
  }
}
</style>


(6)业务组件新增v-model和emitAnswer事件,完善交互。

比如 components/SurveyComs/Materials/SelectComs/SingleSelect.vue:

javascript 复制代码
<template>
  <div
    :class="{
      'text-center': computedState.position,
    }"
  >
    <MaterialsHeader
      :serialNum="serialNum"
      :title="computedState.title"
      :desc="computedState.desc"
      :titleSize="computedState.titleSize"
      :descSize="computedState.descSize"
      :titleWeight="computedState.titleWeight"
      :descWeight="computedState.descWeight"
      :titleItalic="computedState.titleItalic"
      :descItalic="computedState.descItalic"
      :titleColor="computedState.titleColor"
      :descColor="computedState.descColor"
    />
    <div class="radio-group">
      <el-radio-group v-model="radioValue" @click.stop @change="emitAnswer">
        <el-radio v-for="(item, index) in computedState.options" :value="item" :key="index">{{
          item
        }}</el-radio>
      </el-radio-group>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import MaterialsHeader from '@/components/SurveyComs/Common/MaterialsHeader.vue';
import type { OptionsStatus } from '@/types';
import {
  getTextStatus,
  getStringStatus,
  getCurrentStatus,
  getStringStatusByCurrentStatus,
} from '@/utils';
const props = defineProps<{
  serialNum: number;
  status: OptionsStatus;
}>();

const computedState = computed(() => ({
  title: getTextStatus(props.status.title),
  desc: getTextStatus(props.status.desc),
  options: getStringStatus(props.status.options),
  position: getCurrentStatus(props.status.position),
  titleSize: getStringStatusByCurrentStatus(props.status.titleSize),
  descSize: getStringStatusByCurrentStatus(props.status.descSize),
  titleWeight: getCurrentStatus(props.status.titleWeight),
  descWeight: getCurrentStatus(props.status.descWeight),
  titleItalic: getCurrentStatus(props.status.titleItalic),
  descItalic: getCurrentStatus(props.status.descItalic),
  titleColor: getTextStatus(props.status.titleColor),
  descColor: getTextStatus(props.status.descColor),
}));

const radioValue = ref('');

// 回头父组件需要传递一个updateAnswer过来
// 通过触发父组件的这个自定义事件将答案传递给父组件
const emits = defineEmits(['updateAnswer']);

const emitAnswer = () => {
  console.log('11', radioValue.value)
  emits('updateAnswer', radioValue.value);
};
</script>

<style scoped></style>

其他组件同样操作,完成交互和双向通信。

在服务器终端可以看见提交结果:

19. 课程收官

19.1 代码优化方向

  1. 公共方法的提取,比如 getLink、UpdateStatus等。
  2. 仓库接口统一,对外只暴露一个接口,比如 useStore。
  3. 分离组件市场和编辑器。

19.2 完整代码项目

完整的低码项目分为5个部分:

  1. 组件库 / 组件市场
  2. 编辑器
  3. 前台项目:主要是提供给用户使用,包含首页、用户登录、用户专属页、用户创建的问卷、用户创建的模板...
  4. 后台项目:主要是给管理员使用,包含用户管理、问卷管理、模板管理、题库管理...注意,后台也是能够进入到编辑器的,后台拥有创建题库、模板的能力。
  5. 服务器

19.3 完整的低代码项目涉及技术

  1. monorepo(有三种方案)

    (1) npm + lerna

    (2)yarn + workspace

    (3)pnpm + workspace

  2. wailwindcss(原子类css)

  3. 测试框架(Jest 或者 Vitset)

    • 保证功能正确
    • 早期发现问题
    • 提高代码质量
    • 自动化测试
    • 增强信心
  4. JS 工具链

  5. 自定义脚手架:在低码中,有一些组件的解决方案使用脚手架搭建一个组件的架子,然后单独开发、单独发包。针对组件库,可以有如下的解决方案

    (1)通用组件库:提供了90%常用的组件

    (2)单独发包的组件:针对一些功能较为复杂的特殊组件

上一章 《Vue3 低代码平台项目实战(上)

相关推荐
咖啡の猫2 小时前
TypeScript编译选项
前端·javascript·typescript
咖啡の猫4 小时前
TypeScript-webpack
javascript·webpack·typescript
NocoBase5 小时前
GitHub Star 数量前 12 的 AI 工作流项目
人工智能·低代码·开源·github·无代码
叫我阿柒啊18 小时前
从Java全栈到前端框架:一场真实的技术面试对话
java·vue.js·spring boot·微服务·typescript·前端开发·后端开发
API开发平台1 天前
接口开发开源平台 Crabc 3.5.4 发布
低代码·开源
流之云低代码平台1 天前
Gadmin与TPFLOW:打造高效OA系统的最佳搭档
低代码·gadmin·企业信息化oa系统·高效办公oa系统·oa系统选择·企业级开发平台·tpflow工作流引擎
老前端的功夫1 天前
TypeScript 类型守卫:从编译原理到高级模式
前端·javascript·架构·typescript
ttod_qzstudio1 天前
备忘录之事件监听器绑定陷阱:为什么 .bind(this) 会移除失败?
javascript·typescript·内存泄漏·事件监听
流之云低代码平台1 天前
告别繁琐合同管理,智能合同系统来助力
低代码·gadmin·智能合同系统优势·智能合同系统功能模块·智能合同系统应用案例·智能合同系统选择·企业合同管理痛点