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

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

  • [1. 项目架构](#1. 项目架构)
    • [1.1 编辑器](#1.1 编辑器)
    • [1.2 技术栈](#1.2 技术栈)
  • [2. 项目搭建](#2. 项目搭建)
    • [2.1 创建项目](#2.1 创建项目)
    • [2.2 安装依赖](#2.2 安装依赖)
    • [2.3 css 调整](#2.3 css 调整)
      • [2.3.1 基础样式文件](#2.3.1 基础样式文件)
      • [2.3.2 引入样式文件](#2.3.2 引入样式文件)
      • [2.3.3 引入 Element Plus](#2.3.3 引入 Element Plus)
      • [2.3.4 禁用 scss-to-css 插件(没安装则忽视)](#2.3.4 禁用 scss-to-css 插件(没安装则忽视))
    • [2.4 创建首页](#2.4 创建首页)
      • [2.4.1 创建页面](#2.4.1 创建页面)
      • [2.4.2 修改路由文件](#2.4.2 修改路由文件)
  • [3. 搭建组件市场](#3. 搭建组件市场)
    • [3.1 组件市场UI结构](#3.1 组件市场UI结构)
    • [3.2 公共组件 Header](#3.2 公共组件 Header)
    • [3.3 组件市场内容部分](#3.3 组件市场内容部分)
    • [3.4 组件市场详情部分 Layout](#3.4 组件市场详情部分 Layout)
  • [4. 封装单选题组件](#4. 封装单选题组件)
    • [4.1 选择类型组件创建](#4.1 选择类型组件创建)
    • [4.2 创建 业务/物料 组件](#4.2 创建 业务/物料 组件)
    • [4.3 创建编辑项组件](#4.3 创建编辑项组件)
    • [4.4 创建数据仓库](#4.4 创建数据仓库)
    • [4.5 业务组件 关联 数据仓库](#4.5 业务组件 关联 数据仓库)
      • [4.5.1 重构业务组件头部](#4.5.1 重构业务组件头部)
      • [4.5.2 创建 types](#4.5.2 创建 types)
      • [4.5.3 创建工具库](#4.5.3 创建工具库)
      • [4.5.4 重构单选题组件](#4.5.4 重构单选题组件)
      • [4.5.5 数据仓库添加 currentMaterialCom 属性](#4.5.5 数据仓库添加 currentMaterialCom 属性)
      • [4.5.6 修改 Layout 组件,引入数据仓库](#4.5.6 修改 Layout 组件,引入数据仓库)
  • [5. 编辑面板](#5. 编辑面板)
    • [5.1 创建编辑面板](#5.1 创建编辑面板)
    • [5.2 完成标题编辑功能](#5.2 完成标题编辑功能)
    • [5.3 完成描述编辑功能](#5.3 完成描述编辑功能)
    • [5.4 完成选项编辑功能](#5.4 完成选项编辑功能)
    • [5.5 完成居中设置功能](#5.5 完成居中设置功能)
    • [5.6 完成尺寸设置功能](#5.6 完成尺寸设置功能)
    • [5.7 完成加粗、倾斜、颜色设置功能](#5.7 完成加粗、倾斜、颜色设置功能)
    • [5.8 类型守护(bug 修复)](#5.8 类型守护(bug 修复))
      • [5.8.1 排查 bug](#5.8.1 排查 bug)
      • [5.8.2 修复 bug](#5.8.2 修复 bug)
  • [6. 简单回顾](#6. 简单回顾)
    • [6.1 project-tree 插件](#6.1 project-tree 插件)
    • [6.2 使用心得](#6.2 使用心得)
    • [6.3 我的 project tree](#6.3 我的 project tree)
    • [6.4 思路回顾](#6.4 思路回顾)
      • [6.4.1 结构分析](#6.4.1 结构分析)
      • [6.4.2 思路分析](#6.4.2 思路分析)
  • [7. 封装图片题目组件](#7. 封装图片题目组件)
    • [7.1 创建图片上传服务](#7.1 创建图片上传服务)
    • [7.2 根据路由切换业务组件和编辑面板](#7.2 根据路由切换业务组件和编辑面板)
      • [7.2.1 创建对应 JSON Schema 配置文件](#7.2.1 创建对应 JSON Schema 配置文件)
      • [7.2.2 监听路由变化,切换组件](#7.2.2 监听路由变化,切换组件)
    • [7.3 完成图片单选题组件](#7.3 完成图片单选题组件)
    • [7.4 完成右侧图片编辑功能](#7.4 完成右侧图片编辑功能)
    • [7.5 修复警告](#7.5 修复警告)
      • [7.5.1 inject 的方法,TypeScript 无法识别是否为方法](#7.5.1 inject 的方法,TypeScript 无法识别是否为方法)
      • [7.5.2 修复 《不能调用可能是"未定义"的对象》 问题](#7.5.2 修复 《不能调用可能是“未定义”的对象》 问题)
      • [7.5.3 给数据仓库添加类型](#7.5.3 给数据仓库添加类型)
      • [7.5.4 修复《不能将类型"undefined"分配给类型"string"》问题](#7.5.4 修复《不能将类型“undefined”分配给类型“string”》问题)
      • [7.5.5 其他](#7.5.5 其他)
  • [8. 封装备注说明组件](#8. 封装备注说明组件)
    • [8.1 添加备注说明组件](#8.1 添加备注说明组件)
    • [8.2 创建物料组件](#8.2 创建物料组件)
    • [8.3 创建编辑项组件](#8.3 创建编辑项组件)
    • [8.4 创建数据仓库](#8.4 创建数据仓库)
    • [8.5 添加对应 TS 类型(到这步,页面基本出来)](#8.5 添加对应 TS 类型(到这步,页面基本出来))
    • [8.6 完成业务组件(关联数据仓库)](#8.6 完成业务组件(关联数据仓库))
    • [8.7 完成编辑面板(关联数据仓库)](#8.7 完成编辑面板(关联数据仓库))
  • [9. 封装预设组件](#9. 封装预设组件)
    • [9.1 初始化预设组件(性别和学历组件为例)](#9.1 初始化预设组件(性别和学历组件为例))
    • [9.2 初始化预设组件数据](#9.2 初始化预设组件数据)

1. 项目架构

1.1 编辑器

所谓低代码平台,其实就是用户可以通过拖拉拽组件的方式进行画布的绘制,并且对每个组件内容进行的配置。

大致的页面结构如下:

1.2 技术栈

  • vue3
  • vue-router
  • pinia
  • element-plus
  • sass
  • dexie:基于 IndexedDB 的一个库
  • vite
  • typescript
  • eslint
  • prettier
  • fortawesome
  • vuedraggable

2. 项目搭建

2.1 创建项目

(1)执行 pnpm create vue@latest 创建vue3项目,具体配置如下

(2)根据提示,进入项目,下载依赖

javascript 复制代码
cd wenjuan
pnpm i

(3)启动项目

javascript 复制代码
pnpm dev

2.2 安装依赖

(1)安装组件库和图标依赖

javascript 复制代码
pnpm install element-plus @element-plus/icons-vue @fortawesome/fontawesome-svg-core @fortawesome/free-brands-svg-icons @fortawesome/free-regular-svg-icons @fortawesome/free-solid-svg-icons @fortawesome/vue-fontawesome

(2)安装sass依赖

javascript 复制代码
pnpm install sass-embedded -D

2.3 css 调整

2.3.1 基础样式文件

创建assets/css文件夹,用于存储基本样式文件。

(1)reset.scss:

css 复制代码
 body{
  margin: 0;
  padding: 0;
 }
 ul{
  margin: 0;
  padding: 0;
  list-style: none;
 }

(2)common.scss(通用样式文件):

css 复制代码
// 公共类
// font-weight-100 ~ 900
@for $i from 1 through 9 {
  .font-weight-#{$i}00 {
    font-weight: #{$i}00;
  }
}

// margin
// 4个方向 1、2、3、4、5、6、7、8、9、10、15、20、25、30、35、40、45、50
// .mt-1 { margin-top: 1px; } .mr-20 {margin-right:20px}
@each $direction, $property in (t: top, b: bottom, l: left, r: right) {
  @each $size in 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, 35, 40, 45, 50 {
    .m#{$direction}-#{$size} {
      margin-#{$property}: #{$size}px;
    }
  }
}

// padding
// 4个方向 1、2、3、4、5、6、7、8、9、10、15、20、25、30、35、40、45、50
// .pt-1 { padding-top: 1px; } .pr-20 {padding-right:20px}
@each $direction, $property in (t: top, b: bottom, l: left, r: right) {
  @each $size in 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, 35, 40, 45, 50 {
    .p#{$direction}-#{$size} {
      padding-#{$property}: #{$size}px;
    }
  }
}

.border-box {
  box-sizing: border-box;
}

.m0 {
  margin: 0;
}

.p0 {
  padding: 0;
}

.mc {
  margin: 0 auto;
}

.text-center {
  text-align: center;
}

.flex {
  display: flex;
}

.self-start {
  align-items: self-start;
}

.self-center {
  align-items: center;
}

.justify-content-center {
  justify-content: center;
}

.align-items-center {
  align-items: center;
}

.space-between {
  justify-content: space-between;
}

.flex-direction-column {
  flex-direction: column;
}

.wrap {
  flex-wrap: wrap;
}

.font-bold {
  font-weight: 700;
}

.font-italic {
  font-style: italic;
}

.select {
  background-color: var(--primary-color);
  color: var(--white);
}

.pointer {
  cursor: pointer;
}
.relative {
  position: relative;
}
.absolute {
  position: absolute;
}
.fixed {
  position: fixed;
}

(3)variables.scss(主题样式文件):

css 复制代码
:root {
  --primary-color: #409eff;
  --success-color: #67c23a;
  --warning-color: #e6a23c;
  --error-color: #f56c6c;
  --info-color: #909399;
  --border-color: #ebeef5;
  --background-color: #f5f7fa;
  --white: #fff;
  --black: #000;
  --font-color: #303133;
  --font-color-light: #606266;
  --font-color-lighter: #909399;
  --font-color-lightest: #d4d9e2;
  --font-size-base: 14px;
  --font-size-sm: 12px;
  --font-size-lg: 16px;
  --border-radius-base: 4px;
  --border-radius-sm: 2px;
  --border-radius-md: 4px;
  --border-radius-lg: 6px;
  --padding-base: 15px;
  --padding-sm: 10px;
  --padding-lg: 20px;
  --margin-base: 15px;
  --margin-sm: 10px;
  --margin-xs: 5px;
  --margin-lg: 20px;
  --line-height-base: 1.5;
  --line-height-sm: 1.33;
  --line-height-lg: 1.67;
}

(4)index.scss(样式主文件):

css 复制代码
@import url('./common.scss');
@import url('./reset.scss');
@import url('./variables.scss');

2.3.2 引入样式文件

在 main.ts(关键代码) 中引入:

typescript 复制代码
// 引入样式
import './assets/css/index.scss'

2.3.3 引入 Element Plus

在 main.ts(关键代码) 引入:

typescript 复制代码
import { createApp } from 'vue'

// 引入 ElementPlus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// 中文化的支持
import zhCn from 'element-plus/es/locale/lang/zh-cn'

const app = createApp(App)

app.use(ElementPlus, {
  locale: zhCn,
})

2.3.4 禁用 scss-to-css 插件(没安装则忽视)

可以注意到,每次创建一个scss文件,保存时都会生成一个对应的css文件。

这是因为我们之前在学习sass时,安装了 scss-to-css 插件,该插件用于自动将scss文件编译对应的css文件,方便学习验证对应的样式代码。

但是在实际项目中,我们并不需要这个插件,因此需要在VSCode扩展中,选择禁用,然后重启VSCode (可以使用Ctrl + Shift + P,开发者:重新加载窗口)。

最后删除对应css文件,重新保存scss文件,可以发现不再生成对应css文件,验证成功。

2.4 创建首页

2.4.1 创建页面

(1)创建 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="150" />
      <el-table-column prop="title" label="问卷标题" />
      <el-table-column prop="surveyCount" label="题目数" width="150" align="center" />
      <el-table-column prop="updateDate" label="最近更新日期" width="150" align="center" />
      <el-table-column fixed="right" label="操作" width="300" align="center">
        <template> 
          <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()

const tableData = ref([])

const goToEditor = () => {
  router.push('/editor')
}

const goToComMarket = () => {
  router.push('/materials');
};

</script>

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

(2)创建 views/EditorView/Index.vue(编辑器页面):

javascript 复制代码
<template>
  <div>
    编辑器页面
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

(3)创建 views/MaterialsView/Index.vue(组件市场页面):

javascript 复制代码
<template>
  <div>
    组件市场
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

(4)App.vue:

javascript 复制代码
<template>
  <div>
    <RouterView />
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

2.4.2 修改路由文件

修改router/index.ts:

javascript 复制代码
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'),
    },
    {
      path: '/materials',
      name: 'materials',
      component: () => import('@/views/MaterialsView/Index.vue'),
    },
    {
      path: '/editor',
      name: 'editor',
      component: () => import('@/views/EditorView/Index.vue'),
    },
  ],
})

export default router

打开页面,展示如下:

3. 搭建组件市场

3.1 组件市场UI结构

按照UI结构进行划分:

  • 头部公共组件
  • 容器部分
    • 标题
    • 组件
      • 组件类型
      • 组件编辑部分

3.2 公共组件 Header

(1)新建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>
      <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 { useRouter } from 'vue-router';
const router = useRouter();
import { ref } from 'vue';

const goHome = () => {
  router.push('/');
};

const avatar = ref('https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif');
</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>

(2)在 views/MaterialsView/Index.vue(组件市场页面)中引入:

javascript 复制代码
<template>
  <Header />
  <div>
    组件市场
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

3.3 组件市场内容部分

(1)views/MaterialsView/Index.vue(组件市场页面):

javascript 复制代码
<template>
  <Header />
  <div>
    <h1 class="font-weight-100 text-center m0 p0">组件市场</h1>
    <div class="container mc flex">
      <!-- 导航 -->
      <nav class="category mc">
        <Router-link class="category-item" to="/select-group">
          <el-icon>
            <CircleCheck />
          </el-icon>
          <div>选择</div>
        </Router-link>
        <Router-link class="category-item" to="/input-group">
          <el-icon>
            <EditPen />
          </el-icon>
          <div>文本输入</div>
        </Router-link>
        <Router-link class="category-item" to="/advanced-group">
          <el-icon>
            <Files />
          </el-icon>
          <div>高级题型</div>
        </Router-link>
        <Router-link class="category-item" to="/note-group">
          <el-icon>
            <ChatLineSquare />
          </el-icon>
          <div>备注说明</div>
        </Router-link>
        <Router-link class="category-item" to="/personal-info-group">
          <el-icon>
            <User />
          </el-icon>
          <div>个人信息</div>
        </Router-link>
        <Router-link class="category-item" to="/contact-group">
          <el-icon>
            <Message />
          </el-icon>
          <div>联系方式</div>
        </Router-link>
      </nav>
      <!-- 路由出口 -->
      <div class="coms">
        <RouterView />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import Header from '@/components/Common/Header.vue';
// 引入对应图标
import {
  CircleCheck,
  Files,
  EditPen,
  ChatLineSquare,
  User,
  Message,
} from '@element-plus/icons-vue';
</script>

<style scoped lang="scss">
h1 {
  height: 50px;
  margin: 20px 0;
}

.container {
  width: 1180px;
  height: 600px;
}

.category {
  width: 70px;
  height: 100%;

  >.category-item {
    width: 70px;
    height: 70px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    margin-bottom: 10px;
    text-align: center;
    text-decoration: none;
    font-size: var(--font-size-base);
    color: var(--white);
    border-top-left-radius: var(--border-radius-lg);
    border-bottom-left-radius: var(--border-radius-lg);
  }

  @for $i from 1 through 4 {
    .category-item:nth-child(4n + #{$i}) {
      @if $i ==1 {
        background-color: var(--primary-color);
      }

      @else if $i ==2 {
        background-color: var(--success-color);
      }

      @else if $i ==3 {
        background-color: var(--warning-color);
      }

      @else if $i ==4 {
        background-color: var(--error-color);
      }
    }
  }
}

.coms {
  width: calc(1180px - 60px);
  height: 100%;
}
</style>

(2)在views/MaterialsView文件夹下,新建以下6个类型组件:

  • 选择(SelectGroupView)
  • 文本输入(InputGroupView)
  • 高级题型(AdvancedGroupView)
  • 备注说明(NoteGroupView)
  • 个人信息(PersonalInfoGroupView)
  • 联系方式组件(ContactGroupView)

比如选择组件(views/MaterialsView/SelectGroupView.vue):

javascript 复制代码
<template>
  <div>选择组件视图</div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

(3)在 router/index.ts 中以嵌套路由的方式引入:

javascript 复制代码
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'),
    },
    {
      path: '/materials',
      name: 'materials',
      component: () => import('@/views/MaterialsView/Index.vue'),
      redirect: '/select-group',
      children: [
        {
          path: '/select-group',
          name: 'select-group',
          component: () => import('@/views/MaterialsView/SelectGroupView.vue'),
        },
        {
          path: '/input-group',
          name: 'input-group',
          component: () => import('@/views/MaterialsView/InputGroupView.vue'),
        },
        {
          path: '/advanced-group',
          name: 'advanced-group',
          component: () => import('@/views/MaterialsView/AdvancedGroupView.vue'),
        },
        {
          path: '/note-group',
          name: 'note-group',
          component: () => import('@/views/MaterialsView/NoteGroupView.vue'),
        },
        {
          path: '/personal-info-group',
          name: 'personal-info-group',
          component: () => import('@/views/MaterialsView/PersonalInfoGroupView.vue'),
        },
        {
          path: '/contact-group',
          name: 'contact-group',
          component: () => import('@/views/MaterialsView/ContactGroupView.vue'),
        },
      ]
    },
    {
      path: '/editor',
      name: 'editor',
      component: () => import('@/views/EditorView/Index.vue'),
    },
  ],
})

export default router

3.4 组件市场详情部分 Layout

(1)新建 views/MaterialsView/Layout.vue:

javascript 复制代码
<template>
  <div class="layout-container flex">
    <!-- 选择具体的业务组件 -->
    <div class="left flex wrap space-between">
      <slot />
    </div>
    <!-- 显示对应的业务组件 -->
    <div class="center">显示对应的业务组件</div>
    <!-- 编辑面板 -->
    <div class="right">编辑面板</div>
  </div>
</template>

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

<style scoped lang="scss">
.layout-container {
  width: 100%;
  // Header组件高度50px,h1高度50px,上下margin 20px,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 20px);
  align-items: flex-start;
  border: 1px solid var(--border-color);
  border-top-right-radius: var(--border-radius-lg);
  border-bottom-left-radius: var(--border-radius-lg);
  border-bottom-right-radius: var(--border-radius-lg);
}
.left {
  width: 180px;
  text-align: center;
  align-items: flex-start;
  padding: 20px;
}
.center {
  width: 550px;
  // 多减去的60px是上下的padding,,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 60px - 20px);
  overflow-y: scroll;
  padding: 30px;
  border-left: 1px solid var(--border-color);
}
.right {
  width: 350px;
  height: calc(100vh - 100px - 40px - 20px);
  overflow-y: scroll;
  border-left: 1px solid var(--border-color);
}
</style>

(2)创建 assets/css/coms.scss 组件样式文件:

javascript 复制代码
.link-item {
  display: block;
  width: 80px;
  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;
  text-decoration: none;
  line-height: 30px;
}
.link-item:hover {
  background-color: var(--info-color);
  color: var(--white);
}
.link-item-active {
  background-color: var(--info-color);
  color: var(--white);
}

(3)在 assets/css/index.scss 中引入 coms.scss

javascript 复制代码
@import url('./common.scss');
@import url('./reset.scss');
@import url('./variables.scss');
@import url('./coms.scss');

(4)在 views/MaterialsView/SelectGroup.vue 中引入 Layout:

javascript 复制代码
<template>
  <Layout>
    <Router-link class="link-item mb-15">单选题</Router-link>
    <Router-link class="link-item mb-15">多选题</Router-link>
    <Router-link class="link-item mb-15">下拉选择</Router-link>
    <Router-link class="link-item mb-15">图片单选题</Router-link>
    <Router-link class="link-item mb-15">图片多选题</Router-link>
  </Layout>
</template>

<script setup lang="ts">
import Layout from './Layout.vue';
</script>

<style scoped></style>

4. 封装单选题组件

4.1 选择类型组件创建

(1)创建 components/SurveyComs/Materials/SelectComs 文件夹,用于存放单选题组件;

(2)在该文件夹下,创建多个选择组件:

  • 创建单选题(SingleSelect)
  • 多选题(MultiSelect)
  • 下拉选择题(OptionSelect)
  • 图片单选题(SinglePicSelect)
  • 图片多选题(MultiPicSelect)。

比如,单选题组件

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

javascript 复制代码
<template>
  <div>单选题组件</div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

(3)在 router/main.ts 中引入:

javascript 复制代码
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'),
    },
    {
      path: '/materials',
      name: 'materials',
      component: () => import('@/views/MaterialsView/Index.vue'),
      redirect: '/select-group',
      children: [
        {
          path: '/select-group',
          name: 'select-group',
          component: () => import('@/views/MaterialsView/SelectGroupView.vue'),
          redirect: '/single-select',
          children: [
            {
              path: '/single-select',
              name: 'single-select',
              component: () => import('@/components/SurveyComs/Materials/SelectComs/SingleSelect.vue'),
            },
            {
              path: '/multi-select',
              name: 'multi-select',
              component: () =>
                import('@/components/SurveyComs/Materials/SelectComs/MultiSelect.vue'),
            },
            {
              path: '/option-select',
              name: 'option-select',
              component: () =>
                import('@/components/SurveyComs/Materials/SelectComs/OptionSelect.vue'),
            },
            {
              path: '/single-pic-select',
              name: 'single-pic-select',
              component: () =>
                import('@/components/SurveyComs/Materials/SelectComs/SinglePicSelect.vue'),
            },
            {
              path: '/multi-pic-select',
              name: 'multi-pic-select',
              component: () =>
                import('@/components/SurveyComs/Materials/SelectComs/MultiPicSelect.vue'),
            },
          ],
        },
        {
          path: '/input-group',
          name: 'input-group',
          component: () => import('@/views/MaterialsView/InputGroupView.vue'),
        },
        {
          path: '/advanced-group',
          name: 'advanced-group',
          component: () => import('@/views/MaterialsView/AdvancedGroupView.vue'),
        },
        {
          path: '/note-group',
          name: 'note-group',
          component: () => import('@/views/MaterialsView/NoteGroupView.vue'),
        },
        {
          path: '/personal-info-group',
          name: 'personal-info-group',
          component: () => import('@/views/MaterialsView/PersonalInfoGroupView.vue'),
        },
        {
          path: '/contact-group',
          name: 'contact-group',
          component: () => import('@/views/MaterialsView/ContactGroupView.vue'),
        },
      ],
    },
    {
      path: '/editor',
      name: 'editor',
      component: () => import('@/views/EditorView/Index.vue'),
    },
  ],
});

export default router;

(4)在 views/MaterialsView/Layout.vue 中使用:

javascript 复制代码
<template>
  <div class="layout-container flex">
    <!-- 选择具体的业务组件 -->
    <div class="left flex wrap space-between">
      <slot />
    </div>
    <!-- 显示对应的业务组件 -->
    <div class="center">
      <Router-View v-slot="{Component}">
        <component :is="Component" />
      </Router-View>
    </div>
    <!-- 编辑面板 -->
    <div class="right">编辑面板</div>
  </div>
</template>

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

<style scoped lang="scss">
.layout-container {
  width: 100%;
  // Header组件高度50px,h1高度50px,上下margin 20px,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 20px);
  align-items: flex-start;
  border: 1px solid var(--border-color);
  border-top-right-radius: var(--border-radius-lg);
  border-bottom-left-radius: var(--border-radius-lg);
  border-bottom-right-radius: var(--border-radius-lg);
}
.left {
  width: 180px;
  text-align: center;
  align-items: flex-start;
  padding: 20px;
}
.center {
  width: 550px;
  // 多减去的60px是上下的padding,,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 60px - 20px);
  overflow-y: scroll;
  padding: 30px;
  border-left: 1px solid var(--border-color);
}
.right {
  width: 350px;
  height: calc(100vh - 100px - 40px - 20px);
  overflow-y: scroll;
  border-left: 1px solid var(--border-color);
}
</style>

4.2 创建 业务/物料 组件

(1)创建 components/SurveyComs/Common/MaterialsHeader.vue:

javascript 复制代码
<template>
  <div>
    <div class="container mb-15">
      <!-- 标题 -->
      <h2 class="title font-weight-100">这是一个标题</h2>
      <!-- 描述 -->
      <div class="desc">这是一个描述</div>
    </div>
  </div>
</template>

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

<style scoped lang="scss">
.container {
  > h2 {
    font-size: 20px;
    > span {
      margin: 0 5px;
    }
  }
}
.desc {
  font-size: var(--font-size-base);
  color: var(--font-color-light);
  text-indent: 5px;
}
</style>

(2)components/SurveyComs/Materials/SelectComs/SingleSelect.vue 中引入:

javascript 复制代码
<template>
  <div>
    <MaterialsHeader />
    <div class="radio-group">
      <el-radio-group>
        <el-radio :value="1">选项1</el-radio>
        <el-radio :value="2">选项2</el-radio>
        <el-radio :value="3">选项3</el-radio>
        <el-radio :value="4">选项4</el-radio>
      </el-radio-group>
    </div>
  </div>
</template>

<script setup lang="ts">
import MaterialsHeader from '@/components/SurveyComs/Common/MaterialsHeader.vue';
</script>

<style scoped></style>

4.3 创建编辑项组件

(1)创建components/SurveyComs/EditItems文件夹,用于存放编辑项组件;

(2)在该文件夹下,创建多个编辑项组件:

  • 标题编辑组件 TitleEditor
  • 描述编辑组件 DescEditor
  • 选项编辑组件 OptionsEditor
  • 居中位置设置组件 PositionEditor
  • 标题/描述 尺寸编辑组件 SizeEditor
  • 字体粗细组件 WeightEditor
  • 字体倾斜组件 ItalicEditor
  • 颜色编辑组件 ColorEditor

比如 TitleEditor.vue:

javascript 复制代码
<template>
  <div>标题编辑组件</div>
</template>

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

<style scoped></style>

4.4 创建数据仓库

使用 JSON-Schema 配置,将业务组件和编辑项组件形成对应关系。

(1)下载 uuid 依赖,用于生成随机id

javascript 复制代码
pnpm i uuid

(2)创建 configs/defaultStatus/SingleSelect.ts(记录单选业务组件对应的各个编辑项组件):

这里返回的是一个函数而非对象,其实和Vue2的模式有点像,都是为了返回一个单独的实例,互不影响。

javascript 复制代码
import SingleSelect from '@/components/SurveyComs/Materials/SelectComs/SingleSelect.vue';
// 编辑组件
import TitleEditor from '@/components/SurveyComs/EditItems/TitleEditor.vue';
import DescEditor from '@/components/SurveyComs/EditItems/DescEditor.vue';
import OptionsEditor from '@/components/SurveyComs/EditItems/OptionsEditor.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 { markRaw } from 'vue';
import { v4 as uuidv4 } from 'uuid';

export default function () {
  return {
    type: markRaw(SingleSelect),
    name: 'single-select',
    id: uuidv4(),
    // 组件的状态:组件的每一个能够修改的状态都应该对应一个编辑组件
    status: {
      title: {
        id: uuidv4(),
        status: '单选题默认标题',
        isShow: true,
        name: 'title-editor',
        editCom: markRaw(TitleEditor),
      },
      desc: {
        id: uuidv4(),
        status: '单选题默认描述',
        isShow: true,
        name: 'desc-editor',
        editCom: markRaw(DescEditor),
      },
      options: {
        id: uuidv4(),
        status: ['默认选项1', '默认选项2'],
        currentStatus: 0,
        isShow: true,
        name: 'options-editor',
        editCom: markRaw(OptionsEditor),
      },
      position: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['左对齐', '居中对齐'],
        isShow: true,
        name: 'position-editor',
        editCom: markRaw(PositionEditor),
      },
      titleSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['22', '20', '18'],
        isShow: true,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      descSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['16', '14', '12'],
        isShow: true,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      titleWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      descWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      titleItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      descItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      titleColor: {
        id: uuidv4(),
        status: '#000',
        isShow: true,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
      descColor: {
        id: uuidv4(),
        status: '#909399',
        isShow: true,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
    },
  };
}

(3)创建 configs/defaultStatus/defaultStatusMap.ts(用于定义每个业务组件默认初始状态的映射表):

javascript 复制代码
// 该文件用于定义默认状态的映射表
import singleSelectDefaultStatus from './SingleSelect';

export const defaultStatusMap = {
  'single-select': singleSelectDefaultStatus,
};

(4)创建 stores/useMaterial.ts(组件市场里面所有组件状态的仓库):

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
    },
  }),
});

4.5 业务组件 关联 数据仓库

4.5.1 重构业务组件头部

components/SurveyComs/Common/MaterialsHeader.vue:

javascript 复制代码
<template>
  <div>
    <div class="container mb-15">
      <!-- 标题 -->
      <h2 class="title font-weight-100" :style="{
        fontSize: `${titleSize}px`,
        color: titleColor,
      }">
        <span class="mr-10">{{ serialNum }}.</span>
        <span :class="{
          'font-bold': !titleWeight,
          'font-italic': !titleItalic,
        }">{{ title }}</span>
      </h2>
      <!-- 描述 -->
      <div class="desc" :class="{
        'font-bold': !descWeight,
        'font-italic': !descItalic,
      }" :style="{
        fontSize: `${descSize}px`,
        color: descColor,
      }">
        {{ desc }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
defineProps({
  serialNum: {
    type: Number,
    required: true,
  },
  title: {
    type: String,
    default: '',
  },
  desc: {
    type: String,
    default: '',
  },
  titleSize: {
    type: String,
    default: '22',
  },
  descSize: {
    type: String,
    default: '16',
  },
  titleWeight: {
    type: Number,
    default: 0,
  },
  descWeight: {
    type: Number,
    default: 0,
  },
  titleItalic: {
    type: Number,
    default: 0,
  },
  descItalic: {
    type: Number,
    default: 0,
  },
  titleColor: {
    type: String,
    default: '',
  },
  descColor: {
    type: String,
    default: '',
  },
});
</script>

<style scoped lang="scss">
.container {
  >h2 {
    font-size: 20px;

    >span {
      margin: 0 5px;
    }
  }
}

.desc {
  font-size: var(--font-size-base);
  color: var(--font-color-light);
  text-indent: 5px;
}
</style>

4.5.2 创建 types

创建 types 文件夹,用于存储设置项和组件的类型

(1)创建 common.ts(导出 vue 组件类型):

javascript 复制代码
import type { defineComponent } from 'vue';

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

(2)创建 editProps.ts(导出各个设置项的类型):

javascript 复制代码
import type { VueComType } from './common';

// 基础属性,每个组件都有的属性,抽取出来,方便后续扩展
export interface BaseProps {
  id: string;
  isShow: boolean;
  name: string;
  editCom: VueComType;
}

export type StringStatusArr = string[];
export type ValueStatusArr = Array<{ value: string; status: string }>;
export type PicTitleDescStatusArr = Array<{
  picTitle: string;
  picDesc: string;
  value: string;
}>;

// 文本属性设置项
export interface TextProps extends BaseProps {
  status: string;
}

// 选项属性设置项,单选、多选、下拉框等都有的设置项
export interface OptionsProps extends BaseProps {
  status: StringStatusArr | ValueStatusArr | PicTitleDescStatusArr;
  currentStatus: number;
}

// 公共的设置项,每个组件都有的设置项
export interface BaseStatus {
  title: TextProps;
  desc: TextProps;
  position: OptionsProps;
  titleSize: OptionsProps;
  descSize: OptionsProps;
  titleWeight: OptionsProps;
  descWeight: OptionsProps;
  titleItalic: OptionsProps;
  descItalic: OptionsProps;
  titleColor: TextProps;
  descColor: TextProps;
}

// 因为不是所有业务组件都有 options 这个设置项,所以需要分开定义
export interface OptionsStatus extends BaseStatus {
  options: OptionsProps;
}

(3)创建 index.ts,作为类型导出主文件:

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

4.5.3 创建工具库

创建 utils/index.ts(用于获取不同设置项的值):

javascript 复制代码
// 工具库
import type { TextProps, OptionsProps } from '@/types';

export function getTextStatus(props: TextProps) {
  return props.status;
}

export function getStringStatus(props: OptionsProps) {
  return props.status;
}

export function getCurrentStatus(props: OptionsProps) {
  return props.currentStatus;
}

export function getStringStatusByCurrentStatus(props: OptionsProps) {
  return props.status[props.currentStatus];
}

4.5.4 重构单选题组件

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>
        <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 } 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),
}));
</script>

<style scoped></style>

4.5.5 数据仓库添加 currentMaterialCom 属性

数据仓库添加 currentMaterialCom 属性,记录当前选中的组件。

stores\useMaterial.ts:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
    },
  }),
});

4.5.6 修改 Layout 组件,引入数据仓库

views/MaterialsView/Layout.vue:

javascript 复制代码
<template>
  <div class="layout-container flex">
    <!-- 选择具体的业务组件 -->
    <div class="left flex wrap space-between">
      <slot />
    </div>
    <!-- 显示对应的业务组件 -->
    <div class="center">
      <Router-View v-slot="{ Component }">
        <component :is="Component" :status="store.coms[store.currentMaterialCom].status" :serialNum="1" />
      </Router-View>
    </div>
    <!-- 编辑面板 -->
    <div class="right">编辑面板</div>
  </div>
</template>

<script setup lang="ts">
// import { computed } from 'vue';
import { useMaterialStore } from '@/stores/useMaterial';
// 数据仓库
const store = useMaterialStore();
// 获取当前选中组件的状态数据
// const currentCom = computed(() => store.coms[store.currentMaterialCom]);
</script>

<style scoped lang="scss">
.layout-container {
  width: 100%;
  // Header组件高度50px,h1高度50px,上下margin 20px,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 20px);
  align-items: flex-start;
  border: 1px solid var(--border-color);
  border-top-right-radius: var(--border-radius-lg);
  border-bottom-left-radius: var(--border-radius-lg);
  border-bottom-right-radius: var(--border-radius-lg);
}

.left {
  width: 180px;
  text-align: center;
  align-items: flex-start;
  padding: 20px;
}

.center {
  width: 550px;
  // 多减去的60px是上下的padding,,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 60px - 20px);
  overflow-y: scroll;
  padding: 30px;
  border-left: 1px solid var(--border-color);
}

.right {
  width: 350px;
  height: calc(100vh - 100px - 40px - 20px);
  overflow-y: scroll;
  border-left: 1px solid var(--border-color);
}
</style>

5. 编辑面板

5.1 创建编辑面板

(1)修改 types/common.ts,添加 Status(组件编辑项) 类型:

javascript 复制代码
import type { defineComponent } from 'vue';
import type { OptionsStatus } from './editProps';

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

export interface Status {
  type: VueComType;
  name: string;
  id: string;
  status: OptionsStatus;
}

(2)创建 components/SurveyComs/EditItems/EditPannel.vue(编辑面板组件):

javascript 复制代码
<template>
  <div class="editPannelContainer">
    <div v-for="(item, key) in com.status" :key="item.id" class="mb-20">
      <component v-if="item.isShow" :is="item.editCom" :config-key="key" v-bind="item" />
    </div>
  </div>
</template>

<script setup lang="ts">
import type { Status } from '@/types';
defineProps < {
  com: Status;
} > ();
</script>

<style scoped lang="scss">
.editPannelContainer {
  padding: 30px;
  background-color: var(--bg-color);
}
</style>

(3)将编辑面板引入到 views/MaterialsView/Layout.vue 组件中:

javascript 复制代码
<template>
  <div class="layout-container flex">
    <!-- 选择具体的业务组件 -->
    <div class="left flex wrap space-between">
      <slot />
    </div>
    <!-- 显示对应的业务组件 -->
    <div class="center">
      <Router-View v-slot="{ Component }">
        <component :is="Component" :status="store.coms[store.currentMaterialCom].status" :serialNum="1" />
      </Router-View>
    </div>
    <!-- 编辑面板 -->
    <div class="right">
      <EditPannel :com="currentCom" />
    </div>
  </div>
</template>

<script setup lang="ts">
import EditPannel from '@/components/SurveyComs/EditItems/EditPannel.vue';
import { computed } from 'vue';
import { useMaterialStore } from '@/stores/useMaterial';
// 数据仓库
const store = useMaterialStore();
// 获取当前选中组件的状态数据
const currentCom = computed(() => store.coms[store.currentMaterialCom]);

</script>

<style scoped lang="scss">
.layout-container {
  width: 100%;
  // Header组件高度50px,h1高度50px,上下margin 20px,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 20px);
  align-items: flex-start;
  border: 1px solid var(--border-color);
  border-top-right-radius: var(--border-radius-lg);
  border-bottom-left-radius: var(--border-radius-lg);
  border-bottom-right-radius: var(--border-radius-lg);
}

.left {
  width: 180px;
  text-align: center;
  align-items: flex-start;
  padding: 20px;
}

.center {
  width: 550px;
  // 多减去的60px是上下的padding,,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 60px - 20px);
  overflow-y: scroll;
  padding: 30px;
  border-left: 1px solid var(--border-color);
}

.right {
  width: 350px;
  height: calc(100vh - 100px - 40px - 20px);
  overflow-y: scroll;
  border-left: 1px solid var(--border-color);
}
</style>

5.2 完成标题编辑功能

(1)添加 stores/actions.ts(用于修改编辑项状态):

javascript 复制代码
import type { TextProps } from '@/types';
export function setTextStatus(textProps: TextProps, text: string) {
  textProps.status = text;
}

(2)在 stores/useMaterial.ts 中引入:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import { setTextStatus } from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
    },
  }),
  actions: {
    setTextStatus,
  },
});

(3)在 views/MaterialsView/Layout.vue 中加入 updateStatus 方法,用于更新编辑项状态:

javascript 复制代码
<template>
  <div class="layout-container flex">
    <!-- 选择具体的业务组件 -->
    <div class="left flex wrap space-between">
      <slot />
    </div>
    <!-- 显示对应的业务组件 -->
    <div class="center">
      <Router-View v-slot="{ Component }">
        <component :is="Component" :status="store.coms[store.currentMaterialCom].status" :serialNum="1" />
      </Router-View>
    </div>
    <!-- 编辑面板 -->
    <div class="right">
      <EditPannel :com="currentCom" />
    </div>
  </div>
</template>

<script setup lang="ts">
import EditPannel from '@/components/SurveyComs/EditItems/EditPannel.vue';
import { computed, provide } from 'vue';
import { useMaterialStore } from '@/stores/useMaterial';
// 数据仓库
const store = useMaterialStore();
// 获取当前选中组件的状态数据
const currentCom = computed(() => store.coms[store.currentMaterialCom]);

const updateStatus = (configKey: string, payload?: number | string | boolean | object) => {
  // 拿到新的状态数据之后,就应该去修改仓库里面的数据
  switch (configKey) {
    case 'title':
    case 'desc': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "title or desc". Expected string.');
      }
      store.setTextStatus(currentCom.value.status[configKey], payload);
    }
  }
};
provide('updateStatus', updateStatus);
</script>

<style scoped lang="scss">
.layout-container {
  width: 100%;
  // Header组件高度50px,h1高度50px,上下margin 20px,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 20px);
  align-items: flex-start;
  border: 1px solid var(--border-color);
  border-top-right-radius: var(--border-radius-lg);
  border-bottom-left-radius: var(--border-radius-lg);
  border-bottom-right-radius: var(--border-radius-lg);
}

.left {
  width: 180px;
  text-align: center;
  align-items: flex-start;
  padding: 20px;
}

.center {
  width: 550px;
  // 多减去的60px是上下的padding,,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 60px - 20px);
  overflow-y: scroll;
  padding: 30px;
  border-left: 1px solid var(--border-color);
}

.right {
  width: 350px;
  height: calc(100vh - 100px - 40px - 20px);
  overflow-y: scroll;
  border-left: 1px solid var(--border-color);
}
</style>

(4)编写 TitleEditor,实现标题编辑项组件的双向数据绑定。

components/SurveyComs/EditItems/TitleEditor.vue:

javascript 复制代码
<template>
  <div key="id">
    <div class="mb-10">标题内容</div>
    <el-input placeholder="请输入题目标题" v-model="text" @update:modelValue="inputHandle" />
  </div>
</template>

<script setup lang="ts">
import { ref, inject } from 'vue';
import type { VueComType } from '@/types';
const props = defineProps<{
  status: string;
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
  id: string;
}>();

const text = ref(props.status);
const updateStatus = inject('updateStatus');

function inputHandle(newVal: string) {
  updateStatus(props.configKey, newVal);
}
</script>

5.3 完成描述编辑功能

修改描述编辑组件。

components/SurveyComs/EditItems/DescEditor.vue:

javascript 复制代码
<template>
  <div key="id">
    <div class="mb-10">描述内容</div>
    <el-input type="textarea" :rows="5" placeholder="请输入题目描述" v-model="text" @update:modelValue="inputHandle" />
  </div>
</template>

<script setup lang="ts">
import { ref, inject } from 'vue';
import type { VueComType } from '@/types';
const props = defineProps<{
  status: string;
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
  id: string;
}>();

const text = ref(props.status);
const updateStatus = inject('updateStatus');

function inputHandle(newVal: string) {
  updateStatus(props.configKey, newVal);
}
</script>

整体来说,和 TitleEditor 标题编辑组件差别不大,除了改动些文字,只是将input 类型转为 textarea,并且设置为显示 5 行。

其实可以和 TitleEditor 组件合并成一个组件,从而减少代码量,进行优化,这放在最后处理。

5.4 完成选项编辑功能

(1)types/editProps.ts 添加判断是否是字符串数组方法 isStringArray:

javascript 复制代码
import type { VueComType } from './common';

export interface BaseProps {
  id: string;
  isShow: boolean;
  name: string;
  editCom: VueComType;
}

export type StringStatusArr = string[];
export type ValueStatusArr = Array<{ value: string; status: string }>;
export type PicTitleDescStatusArr = Array<{
  picTitle: string;
  picDesc: string;
  value: string;
}>;

export interface TextProps extends BaseProps {
  status: string;
}

export type OptionsStatusArr = StringStatusArr | ValueStatusArr | PicTitleDescStatusArr;

export interface OptionsProps extends BaseProps {
  status: OptionsStatusArr;
  currentStatus: number;
}

// 公共的设置项,每个组件都有的设置项
export interface BaseStatus {
  title: TextProps;
  desc: TextProps;
  position: OptionsProps;
  titleSize: OptionsProps;
  descSize: OptionsProps;
  titleWeight: OptionsProps;
  descWeight: OptionsProps;
  titleItalic: OptionsProps;
  descItalic: OptionsProps;
  titleColor: TextProps;
  descColor: TextProps;
}

// 因为不是所有业务组件都有 options 这个设置项,所以需要分开定义
export interface OptionsStatus extends BaseStatus {
  options: OptionsProps;
}

export function isStringArray(status: OptionsStatusArr): status is string[] {
  return Array.isArray(status) && typeof status[0] === 'string';
}

(2)stores/actions.ts 添加选项新增和删除方法:

javascript 复制代码
import type { TextProps } from '@/types';
import { isStringArray } from '@/types';
export function setTextStatus(textProps: TextProps, text: string) {
  textProps.status = text;
}

export function addOption(optionProps: OptionsProps) {
  if (isStringArray(optionProps.status)) {
    optionProps.status.push('新选项');
  }
}

export function removeOption(optionProps: OptionsProps, index: number) {
  if (optionProps.status.length === 2) {
    return false;
  }
  optionProps.status.splice(index, 1);
  return true;
}

(3)stores/useMaterial.ts 中加入新增方法:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import { setTextStatus, addOption, removeOption } from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
    },
  }),
  actions: {
    setTextStatus,
    addOption,
    removeOption,
  },
});

(4)views/MaterialsView/Layout.vue 加入选项编辑的新增和删除方法相关判断,关键代码:

javascript 复制代码
const updateStatus = (configKey: string, payload?: number | string | boolean | object) => {
  // 拿到新的状态数据之后,就应该去修改仓库里面的数据
  switch (configKey) {
    case 'title':
    case 'desc': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "title or desc". Expected string.');
      }
      store.setTextStatus(currentCom.value.status[configKey], payload);
    }
    case 'options': {
      if (typeof payload === 'number') {
        // 说明是删除选项
        const result = store.removeOption(currentCom.value.status[configKey], payload);
        if (result) ElMessage.success('删除成功');
        else ElMessage.error('至少保留两个选项');
      } else {
        // 说明是新增选项
        store.addOption(currentCom.value.status[configKey]);
      }
    }
  }
};

(5)完成选项编辑组件。

components\SurveyComs\EditItems\OptionsEditor.vue:

javascript 复制代码
<template>
  <div key="id">
    <div class="flex align-items-center mb-10">
      <div class="mr-10">选项</div>
      <el-button size="small" circle :icon="Plus" @click="addOptionHandle" />
    </div>
    <div v-for="(item, index) in status" :key="index" class="flex align-items-center">
      <el-input placeholder="选项" class="mt-5 mb-5" v-model="textArr[index]" />
      <el-button type="danger" class="ml-10" size="small" :icon="Minus" circle @click="removeOption(index)" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, inject } from 'vue';
import { Plus, Minus } from '@element-plus/icons-vue';
const props = defineProps<{
  status: string[];
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
  id: string;
}>();

const textArr = ref(props.status);
const updateStatus = inject('updateStatus');
const addOptionHandle = () => {
  updateStatus(props.configKey);
};

const removeOption = (index: number) => {
  updateStatus(props.configKey, index);
};
</script>

<style scoped></style>

5.5 完成居中设置功能

(1)src/main.ts 中引入 Font Awesome 图标库,关键代码:

javascript 复制代码
// 引入 Font Awesome 图标库
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { fas } from '@fortawesome/free-solid-svg-icons';

const app = createApp(App);

// 将所有 solid 图标添加到库中
library.add(fas);
// 注册一个fontawesome图标组件
app.component('font-awesome-icon', FontAwesomeIcon);

(2)创建 ButtonGroup 组件(因为居中设置、尺寸设置、字体粗细设置的结构样式基本一致,所以抽离出来,按钮组部分作为slot进行插入)。

components/SurveyComs/EditItems/ButtonGroup.vue:

javascript 复制代码
<template>
  <div class="flex align-items-center space-between">
    <!-- 标题以及当前状态 -->
    <div class="flex align-items-center">
      <div class="mr-20">{{ title }}</div>
      <div class="currentStatus">{{ status }}</div>
    </div>
    <!-- 按钮组 -->
    <div>
      <slot />
    </div>
  </div>
</template>

<script setup lang="ts">
defineProps({
  title: {
    type: String,
    default: '',
  },
  status: {
    type: String,
    default: '',
  },
});
</script>

<style scoped lang="scss">
.currentStatus {
  color: var(--info-color);
  font-size: var(--font-size-base);
}
</style>

(3)stores/actions.ts 加入 setPosition 方法,关键代码:

javascript 复制代码
export function setPosition(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}

(4)stores/useMaterial.ts 中引入 setPosition 方法:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import { setTextStatus, addOption, removeOption, setPosition } from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
    },
  }),
  actions: {
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
  },
});

(5)views/MaterialsView/Layout.vue 中使用 setPosition 方法,关键代码:

javascript 复制代码
const updateStatus = (configKey: string, payload?: number | string | boolean | object) => {
  // 拿到新的状态数据之后,就应该去修改仓库里面的数据
  switch (configKey) {
    case 'title':
    case 'desc': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "title or desc". Expected string.');
      }
      store.setTextStatus(currentCom.value.status[configKey], payload);
    }
    case 'options': {
      if (typeof payload === 'number') {
        // 说明是删除选项
        const result = store.removeOption(currentCom.value.status[configKey], payload);
        if (result) ElMessage.success('删除成功');
        else ElMessage.error('至少保留两个选项');
      } else {
        // 说明是新增选项
        store.addOption(currentCom.value.status[configKey]);
      }
    }
    case 'position': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "position". Expected number.');
      }
      store.setPosition(currentCom.value.status[configKey], payload);
    }
  }
};

(6)完成 PositionEditor位置设置编辑组件。

components/SurveyComs/EditItems/PositionEditor.vue:

javascript 复制代码
<template>
  <ButtonGroup title="居中设置" :status="status[currentStatus]">
    <el-button-group>
      <el-button :class="{
        select: currentStatus === 0,
      }" @click="changePosition(0)">
        <font-awesome-icon icon="align-left" />
      </el-button>
      <el-button :class="{
        select: currentStatus === 1,
      }" @click="changePosition(1)">
        <font-awesome-icon icon="align-center" />
      </el-button>
    </el-button-group>
  </ButtonGroup>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import ButtonGroup from './ButtonGroup.vue';
const props = defineProps<{
  currentStatus: number;
  status: string[];
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
}>();
const updateStatus = inject('updateStatus');
const changePosition = (pos: number) => {
  updateStatus(props.configKey, pos);
};
</script>

<style scoped></style>

5.6 完成尺寸设置功能

回顾一下:因为标题尺寸和描述尺寸功能几乎完全一致,所以之前我们将其封装成一个组件。

(1)stores/actions.ts 中添加 setSize 方法:

javascript 复制代码
export function setSize(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}

(2)stores\useMaterial.ts 引入:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import { setTextStatus, addOption, removeOption, setPosition, setSize } from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
    },
  }),
  actions: {
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setSize,
  },
});

(3)在 views\MaterialsView\Layout.vue 中使用,关键代码:

javascript 复制代码
const updateStatus = (configKey: string, payload?: number | string | boolean | object) => {
  // 拿到新的状态数据之后,就应该去修改仓库里面的数据
  switch (configKey) {
    case 'title':
    case 'desc': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "title or desc". Expected string.');
      }
      store.setTextStatus(currentCom.value.status[configKey], payload);
    }
    case 'options': {
      if (typeof payload === 'number') {
        // 说明是删除选项
        const result = store.removeOption(currentCom.value.status[configKey], payload);
        if (result) ElMessage.success('删除成功');
        else ElMessage.error('至少保留两个选项');
      } else {
        // 说明是新增选项
        store.addOption(currentCom.value.status[configKey]);
      }
    }
    case 'position': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "position". Expected number.');
      }
      store.setPosition(currentCom.value.status[configKey], payload);
    }
    case 'titleSize':
    case 'descSize': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "titleSize or descSize". Expected number.');
      }
      store.setSize(currentCom.value.status[configKey], payload);
    }
  }
};
provide('updateStatus', updateStatus);

(4)完成尺寸设置功能。

components/SurveyComs/EditItems/SizeEditor.vue:

javascript 复制代码
<template>
  <ButtonGroup :title="`${configKey === 'titleSize' ? '标题' : '描述'}尺寸`" :status="`${status[currentStatus]}`">
    <el-button-group>
      <el-button :class="{
        select: currentStatus === 0,
      }" @click="changeSize(0)">
        <font-awesome-icon icon="font" size="lg" />
      </el-button>
      <el-button :class="{
        select: currentStatus === 1,
      }" @click="changeSize(1)">
        <font-awesome-icon icon="font" size="sm" />
      </el-button>
      <el-button :class="{
        select: currentStatus === 2,
      }" @click="changeSize(2)">
        <font-awesome-icon icon="font" size="xs" />
      </el-button>
    </el-button-group>
  </ButtonGroup>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import ButtonGroup from './ButtonGroup.vue';
const props = defineProps<{
  currentStatus: number;
  status: string[];
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
}>();
const updateStatus = inject('updateStatus');
const changeSize = (size: number) => {
  updateStatus(props.configKey, size);
};
</script>

<style scoped></style>

5.7 完成加粗、倾斜、颜色设置功能

其实后面的加粗、倾斜、颜色设置功能,和 5.6 完成尺寸设置功能 的步骤没太大区别,标题和描述都是共用一个组件。大家可以自己试着完成下。

我们这里都统一书写,不一个个写了。

注意:这里修复了一个bug,之前在 Layout 组件中 updateStatus 方法的 swaitch 语句忘记添加 break,导致每种情况都会执行一次,从而发生各种报错,这里进行添加,看(3)

最后,我们发现 stores/actions.ts 和 views\MaterialsView\Layout.vue 等组件存在大量相似的代码,后续也可以进行进一步的封装优化。

(1)stores/actions.ts 添加 setWeight、setItalic、setColor 方法,关键代码:

javascript 复制代码
export function setWeight(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}
export function setItalic(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}

export function setColor(colorProps: TextProps, text: string) {
  colorProps.status = text;
}

(2)stores/useMaterial.ts 中引入:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setSize,
  setWeight,
  setItalic,
  setColor,
} from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
    },
  }),
  actions: {
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setSize,
    setWeight,
    setItalic,
    setColor,
  },
});

(3)在 views/MaterialsView/Layout.vue 中使用,关键代码:

javascript 复制代码
const updateStatus = (configKey: string, payload?: number | string | boolean | object) => {
  // 拿到新的状态数据之后,就应该去修改仓库里面的数据
  switch (configKey) {
    case 'title':
    case 'desc': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "title or desc". Expected string.');
      }
      store.setTextStatus(currentCom.value.status[configKey], payload);
      break;
    }
    case 'options': {
      if (typeof payload === 'number') {
        // 说明是删除选项
        const result = store.removeOption(currentCom.value.status[configKey], payload);
        if (result) ElMessage.success('删除成功');
        else ElMessage.error('至少保留两个选项');
      } else {
        // 说明是新增选项
        store.addOption(currentCom.value.status[configKey]);
      }
      break;
    }
    case 'position': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "position". Expected number.');
      }
      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.');
      }
      store.setSize(currentCom.value.status[configKey], payload);
      break;
    }
    case 'titleWeight':
    case 'descWeight': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "titleWeight or descWeight". Expected number.');
      }
      store.setWeight(currentCom.value.status[configKey], payload);
      break;
    }
    case 'titleItalic':
    case 'descItalic': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "titleItalic or descItalic". Expected number.');
      }
      store.setItalic(currentCom.value.status[configKey], payload);
      break;
    }
    case 'titleColor':
    case 'descColor': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "titleColor or descColor". Expected string.');
      }
      store.setColor(currentCom.value.status[configKey], payload);
    }
  }
};
provide('updateStatus', updateStatus);

(4)完成加粗设置功能。components/SurveyComs/EditItems/WeightEditor.vue:

javascript 复制代码
<template>
  <ButtonGroup :title="`${configKey === 'titleWeight' ? '标题' : '描述'}加粗`" :status="`${status[currentStatus]}`">
    <el-button-group>
      <el-button :class="{
        select: currentStatus === 0,
      }" @click="changeWeight(0)">
        <font-awesome-icon icon="b" size="lg" />
      </el-button>
      <el-button :class="{
        select: currentStatus === 1,
      }" @click="changeWeight(1)">
        <font-awesome-icon icon="b" size="xs" />
      </el-button>
    </el-button-group>
  </ButtonGroup>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import ButtonGroup from './ButtonGroup.vue';
const props = defineProps<{
  currentStatus: number;
  status: string[];
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
}>();
const updateStatus = inject('updateStatus');
const changeWeight = (size: number) => {
  updateStatus(props.configKey, size);
};
</script>

<style scoped></style>

(5)完成倾斜设置功能。

components/SurveyComs/EditItems/ItalicEditor.vue:

javascript 复制代码
<template>
  <ButtonGroup :title="`${configKey === 'titleItalic' ? '标题' : '描述'}倾斜`" :status="`${status[currentStatus]}`">
    <el-button-group>
      <el-button :class="{
        select: currentStatus === 0,
      }" @click="changeItalic(0)">
        <font-awesome-icon icon="italic" size="lg" />
      </el-button>
      <el-button :class="{
        select: currentStatus === 1,
      }" @click="changeItalic(1)">
        <font-awesome-icon icon="italic" size="xs" />
      </el-button>
    </el-button-group>
  </ButtonGroup>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import ButtonGroup from './ButtonGroup.vue';
const props = defineProps<{
  currentStatus: number;
  status: string[];
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
}>();
const updateStatus = inject('updateStatus');
const changeItalic = (size: number) => {
  updateStatus(props.configKey, size);
};
</script>

<style lang="scss" scoped>
.el-button-group {
  font-style: italic;
}
</style>

(6)完成颜色设置功能。

components/SurveyComs/EditItems/ColorEditor.vue:

javascript 复制代码
<template>
  <ButtonGroup :title="`${configKey === 'titleColor' ? '标题' : '描述'}颜色`" :status="text">
    <el-color-picker v-model="text" @change="changeColor" />
  </ButtonGroup>
</template>

<script setup lang="ts">
import { ref, inject } from 'vue';
import type { VueComType } from '@/types';
import ButtonGroup from './ButtonGroup.vue';
const props = defineProps<{
  status: string;
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
  id: string;
}>();

const text = ref(props.status);
const updateStatus = inject('updateStatus');

function changeColor(newVal: string) {
  updateStatus(props.configKey, newVal);
}
</script>

5.8 类型守护(bug 修复)

5.8.1 排查 bug

上面的功能基本已经完成,但是我们注意到,单选题组件页面(components/SurveyComs/Materials/SelectComs/SingleSelect.vue)有一些报错。

从信息上,可以看到 titleSizedescSize 两个字段,我们在组件中定义了对应的 propstring 类型。

但是报错信息告诉我们,这两个字段实际上可能 string | { picTitle: string; picDesc: string; value: string; } | { value: string; status: string; } | undefined 这样的联合类型。

从数据源查找,我们发现 getStringStatusByCurrentStatus(utils/index.ts):

javascript 复制代码
export function getStringStatusByCurrentStatus(props: OptionsProps) {
  return props.status[props.currentStatus];
}

参数 props 的类型为 OptionsProps,其中的 status 参数为3种数组的联合类型,因此 typescript 推断出对应的项也应该是对应项的联合类型。

5.8.2 修复 bug

修改 utils/index.ts 中的 getStringStatusByCurrentStatus 方法,添加属性的类型判断即可。关键代码:

javascript 复制代码
export function getStringStatusByCurrentStatus(props: OptionsProps) {
  if (props && isStringArray(props.status)) {
    return props.status[props.currentStatus];
  }
}

修复后,报错提示自然就消失了。

6. 简单回顾

6.1 project-tree 插件

这里和大家介绍一个好用的VSCode插件:project-tree。

正如截图中所介绍的一样:安装插件后,按下Ctrl+Shift+P并输入"Project Tree"以激活该插件。该插件将在README.md文件中生成项目的树状结构。

如果需要更为详细的配置,我们可以通过 管理 -> 设置 -> 扩展 -> ProjectTree configurantion 进行修改。

配置项总共有6项:

配置项名称 默认值 描述
With Comment false 每个文件后是否需要有注释
Comment Distance 5 注释和文件名的整体距离,最小为1
Dist File Name REEADME.md 项目树的输出文件名
Ignore Folders ['node_modules', '.git', '.vscode'] 忽略的文件列表
Load Ignore true 是否也忽略 .ignore 文件中的文件
Theme perfect 项目树的外观主题,有 perfect 和 normal 两种

6.2 使用心得

通常来说,使用配置项的默认值已经足够。不过我们可以根据自身需要或者审美进行调整:

(1)With Comment 设置为 true。我们生成项目树的目的,通常是为了对项目结构有更加直观的了解,因此注释还是有一定必要的。当然,如果你希望直接在文件名后添加文字,而非整体对齐的 // 的注释方式,可以仍设置为false

(2)Comment Distance 仍保持默认为 5

(3)Dist File Name 保持默认值为 REEADME.md。但是如果你希望单独一个文件用于存储,可以进行修改,比如 PROJECTTREE.md

(4)Ignore Folders 可保持默认值为 ['node_modules', '.git', '.vscode'] ,看具体项目而定,有需要就进行修改;

(5)Load Ignore 可保持 true

(6)Theme 保持 perfect,试过使用 normal,样式确实不如 perfect 好看,怪不得人家叫 perfect 呢;

(7)第一次 Ctrl+Shift+P并输入"Project Tree"以激活该插件,会先判断 Dist File Name 配置项文件是否存在,如果不存在,就生成该文件,然后生成对应的 project tree;

(2)如果 Dist File Name 文件,已经存在

6.3 我的 project tree

我设置了 With Commenttrue,并且修改 distFileNamePROJECTTREE.md,其他设置保持为默认。在VSCode中打开 wenjuan (项目文件夹),点击 Ctrl+Shift+P 并输入 Project Tree 回车,生成 PROJECTTREE.md 文件(当然,具体的注释内容为手写):

javascript 复制代码
wenjuan                                   // 问卷调查系统(低代码)
├─ .editorconfig                          //
├─ .prettierrc.json                       //
├─ env.d.ts                               //
├─ eslint.config.ts                       //
├─ index.html                             //
├─ package.json                           //
├─ pnpm-lock.yaml                         //
├─ public                                 //
│  └─ favicon.ico                         //
├─ README.md                              //
├─ src                                    //
│  ├─ App.vue                             //
│  ├─ assets                              // 项目静态资源文件夹
│  │  ├─ css                              //
│  │  │  ├─ common.scss                   // 公共样式文件(例如:.font-weight-100、.ml-8、.pr-25、.flex、.justify-content-center等)
│  │  │  ├─ coms.scss                     // 组件样式文件(例如:.link-item)
│  │  │  ├─ index.scss                    // 引入所有样式文件(例如:@import url('./common.scss');)(sass模块化分为运行时和编译时,@import url('./common.scss'); 属于编译时,各个模块变量不会相互影响;去掉url则为运行时,各个模块变量会相互影响)
│  │  │  ├─ reset.scss                    // 重置样式文件(例如:去除默认的margin、padding等)
│  │  │  └─ variables.scss                // 全局变量文件(例如:$primary-color)
│  │  └─ img                              //
│  ├─ components                          // 组件文件夹
│  │  ├─ Common                           // 通用组件文件夹
│  │  │  └─ Header.vue                    //
│  │  ├─ Editor                           //
│  │  └─ SurveyComs                       // 问卷组件文件夹
│  │     ├─ Common                        //
│  │     │  └─ MaterialsHeader.vue        //
│  │     ├─ EditItems                     // 编辑项组件文件夹(组件市场右侧,除 ButtonGroup 这种内部通用组件外,其他文件以Editor结尾,表示编辑组件)
│  │     │  ├─ ButtonGroup.vue            //
│  │     │  ├─ ColorEditor.vue            //
│  │     │  ├─ DescEditor.vue             //
│  │     │  ├─ EditPannel.vue             //
│  │     │  ├─ ItalicEditor.vue           //
│  │     │  ├─ OptionsEditor.vue          //
│  │     │  ├─ PositionEditor.vue         //
│  │     │  ├─ SizeEditor.vue             //
│  │     │  ├─ TitleEditor.vue            //
│  │     │  └─ WeightEditor.vue           //
│  │     └─ Materials                     // 物料/业务组件目录(组件市场中心内容)
│  │        ├─ InputComs                  //
│  │        └─ SelectComs                 // 选择组件文件夹
│  │           ├─ MultiPicSelect.vue      //
│  │           ├─ MultiSelect.vue         //
│  │           ├─ OptionSelect.vue        //
│  │           ├─ SinglePicSelect.vue     //
│  │           └─ SingleSelect.vue        // 单选组件(选择组视图文件默认展示的物料组件)
│  ├─ configs                             // 配置文件夹
│  │  └─ defaultStatus                    //
│  │     ├─ defaultStatusMap.ts           // 默认状态映射文件(用于初始化组件属性值)
│  │     └─ SingleSelect.ts               // 默认单选组件配置文件(用于初始化组件属性值)
│  ├─ main.ts                             // 入口文件(Vue3项目)
│  ├─ router                              // 路由文件夹
│  │  └─ index.ts                         //
│  ├─ stores                              // 状态管理文件夹
│  │  ├─ actions.ts                       // 存储仓库的 actions文件(Pinia),在useMaterial中引入
│  │  └─ useMaterial.ts                   // 物料/业务组件状态仓库文件(Pinia)
│  ├─ types                               // 字段类型文件夹(用于typescript类型定义)
│  │  ├─ common.ts                        // 公共类型定义文件(例如:VueComType)
│  │  ├─ editProps.ts                     // 编辑器组件属性类型定义文件
│  │  └─ index.ts                         // 导出所有类型定义文件
│  ├─ utils                               // 工具文件夹
│  │  └─ index.ts                         // 工具库文件(例如:getTextStatus),用于快速获取组件属性值等操作
│  └─ views                               // 视图文件夹(除Index、Layout等特殊文件,其他文件以View结尾,表示视图组件)
│     ├─ EditorView                       //
│     │  └─ Index.vue                     //
│     ├─ HomeView.vue                     //
│     └─ MaterialsView                    // 物料/业务组件视图文件夹(组件市场中心内容)
│        ├─ AdvancedGroupView.vue         //
│        ├─ ContactGroupView.vue          //
│        ├─ Index.vue                     // 物料/业务组件视图入口文件(组件市场中心内容)
│        ├─ InputGroupView.vue            //
│        ├─ Layout.vue                    //
│        ├─ NoteGroupView.vue             //
│        ├─ PersonalInfoGroupView.vue     //
│        └─ SelectGroupView.vue           // 选择组组件视图文件(默认展示选择组组件)
├─ tsconfig.app.json                      //
├─ tsconfig.json                          //
├─ tsconfig.node.json                     //
└─ vite.config.ts                         //

6.4 思路回顾

6.4.1 结构分析


(1)整个组件市场都使用了路由重定向,确保只有一层路由;

(2)基本都直接使用 <RouterView /> 嵌入下一级路由内容,在第三级路由 /single-select嵌入时,因为要传递参数,所以使用了 <RouterView /> 结合 v-slot 插槽的方式:

javascript 复制代码
<Router-View v-slot="{ Component }">
  <component :is="Component" :status="store.coms[store.currentMaterialCom].status" :serialNum="1" />
</Router-View>

(3) 因为第三级路由结构一致,所以抽象出来,封装成 Layout 组件。

6.4.2 思路分析

(1)其实,整个编辑器部分,最重要的就是数据仓库。因为无论是物料组件,还是编辑项,都是操作数据仓库中的内容;

(2)所有组件层级的实际获取数据仓库的数据和操作方法,都在 Layout 组件中。

其中业务组件(第三级路由组件,比如singleSelectView)部分只负责展示;

编辑面板组件 EditPannel 遍历展示和操作当前实例的所有属性编辑器组件内容。

javascript 复制代码
<template>
  <div class="layout-container flex">
    <!-- 选择具体的业务组件 -->
    <div class="left flex wrap space-between">
      <slot />
    </div>
    <!-- 显示对应的业务组件 -->
    <div class="center">
      <Router-View v-slot="{ Component }">
        <component :is="Component" :status="store.coms[store.currentMaterialCom].status" :serialNum="1" />
      </Router-View>
    </div>
    <!-- 编辑面板 -->
    <div class="right">
      <EditPannel :com="currentCom" />
    </div>
  </div>
</template>

7. 封装图片题目组件

7.1 创建图片上传服务

(1)新建一个文件夹,比如 server

(2)初始化 package.json

javascript 复制代码
pnpm init

(3)下载依赖:

javascript 复制代码
pnpm i body-parser express multer

(4)创建 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 编码请求体

// 设置 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");
});

(5)修改 package.json,添加启动服务命令,关键代码:

javascript 复制代码
"scripts": {
   "start": "node server.js"
 },

(6)启动服务

javascript 复制代码
pnpm start

(7)修改 wenjuan 项目的 vite.config.ts,添加对应代理(proxy):

javascript 复制代码
import { fileURLToPath, URL } from 'node:url';

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import vueDevTools from 'vite-plugin-vue-devtools';

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx(), vueDevTools()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  server: {
    proxy: {
      '/api': 'http://localhost:3001',
      '/uploads': 'http://localhost:3001',
    },
  },
});

7.2 根据路由切换业务组件和编辑面板

通过观察,我们知道,组件市场的业务组件和编辑面板是根据当前选中的组件进行渲染的。

接下来我们要做的就是切换路由的时候,将当前选中组件进行切换。

但是并非每次切换路由的时候需要做这件事,只有在组件市场页面中时,才需要,所以我们可以提供一个标记,用于记录当前的页面。

7.2.1 创建对应 JSON Schema 配置文件

(1)创建图片单选题组件的JSON Schema 配置文件。

可以先简单复制单选题组件的文件,做些修改用于区分,后续再完成具体功能。

configs/defaultStatus/SinglePicSelect.ts:

javascript 复制代码
import SinglePicSelect from '@/components/SurveyComs/Materials/SelectComs/SinglePicSelect.vue';
// 编辑组件
import TitleEditor from '@/components/SurveyComs/EditItems/TitleEditor.vue';
import DescEditor from '@/components/SurveyComs/EditItems/DescEditor.vue';
import OptionsEditor from '@/components/SurveyComs/EditItems/OptionsEditor.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 { markRaw } from 'vue';
import { v4 as uuidv4 } from 'uuid';

export default function () {
  return {
    type: markRaw(SinglePicSelect),
    name: 'single-pic-select',
    id: uuidv4(),
    // 组件的状态:组件的每一个能够修改的状态都应该对应一个编辑组件
    status: {
      title: {
        id: uuidv4(),
        status: '图片单选题默认标题',
        isShow: true,
        name: 'title-editor',
        editCom: markRaw(TitleEditor),
      },
      desc: {
        id: uuidv4(),
        status: '单选题默认描述',
        isShow: true,
        name: 'desc-editor',
        editCom: markRaw(DescEditor),
      },
      options: {
        id: uuidv4(),
        status: ['默认选项1', '默认选项2'],
        currentStatus: 0,
        isShow: true,
        name: 'options-editor',
        editCom: markRaw(OptionsEditor),
      },
      position: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['左对齐', '居中对齐'],
        isShow: true,
        name: 'position-editor',
        editCom: markRaw(PositionEditor),
      },
      titleSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['22', '20', '18'],
        isShow: true,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      descSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['16', '14', '12'],
        isShow: true,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      titleWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      descWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      titleItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      descItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      titleColor: {
        id: uuidv4(),
        status: '#000',
        isShow: true,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
      descColor: {
        id: uuidv4(),
        status: '#909399',
        isShow: true,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
    },
  };
}

(2)configs/defaultStatus/defaultStatusMap.ts 添加映射:

javascript 复制代码
// 该文件用于定义默认状态的映射表
import singleSelectDefaultStatus from './SingleSelect';
import singlePicSelectDefaultStatus from './SinglePicSelect';

export const defaultStatusMap = {
  'single-select': singleSelectDefaultStatus,
  'single-pic-select': singlePicSelectDefaultStatus,
};

7.2.2 监听路由变化,切换组件

(1)数据仓库添加切换当前组件方法。

stores/useMaterial.ts:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setSize,
  setWeight,
  setItalic,
  setColor,
  setPicLinkByIndex,
} from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
      'single-pic-select': defaultStatusMap['single-pic-select'](),
    },
  }),
  actions: {
    setCurrentMaterialCom(comName: string) {
      this.currentMaterialCom = comName;
    },
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setSize,
    setWeight,
    setItalic,
    setColor,
    setPicLinkByIndex,
  },
});

(2)在 views/HomeView.vue 中添加 activeView,存储当前活跃视图,关键代码:

javascript 复制代码
const goToEditor = () => {
  localStorage.setItem('activeView', 'editor');
  router.push('/editor');
};

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

(3)components/Common/Header.vue 同理,关键代码:

javascript 复制代码
const goHome = () => {
  localStorage.setItem('activeView', 'home');
  router.push('/');
};

(4)在 router\index.ts 添加路由守卫,根据 activeView 判断是否切换当前组件:

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

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
   // 代码省略
  ],
});

router.beforeEach((to, from, next) => {
  // 设置之前需要判断一下是否是组件市场
  // 因为只有组件市场需要记录当前的组件
  const activeView = localStorage.getItem('activeView');
  const store = useMaterialStore();
  if (activeView === 'materials' && to.name) {
    store.setCurrentMaterialCom(to.name as string);
  }
  next();
});

export default router;

7.3 完成图片单选题组件


注意,本小节(1)~(5)为主要文件修改,(6)~(9)为补充修改。

(1)创建图片单选编辑项组件 PicOptionsEditor(占位,后续改造)。components/SurveyComs/EditItems/PicOptionsEditor.vue:

javascript 复制代码
<template>
  <div>
    <div class="flex align-items-center">
      <div class="mr-10">题目选项</div>
    </div>
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

(2)改造 configs/defaultStatus/SinglePicSelect.ts,引入PicOptionsEditor

通过观察,可以发现右侧除了题目选项外,图片单选题和单选题的编辑项几乎一致。

javascript 复制代码
// 单选题的 JSON-Schema 配置
// 单选题(业务组件) ---> 编辑组件有哪些

// 业务组件
import SinglePicSelect from '@/components/SurveyComs/Materials/SelectComs/SinglePicSelect.vue';
// 编辑组件
import TitleEditor from '@/components/SurveyComs/EditItems/TitleEditor.vue';
import DescEditor from '@/components/SurveyComs/EditItems/DescEditor.vue';
import PicOptionsEditor from '@/components/SurveyComs/EditItems/PicOptionsEditor.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 { markRaw } from 'vue';
import { v4 as uuidv4 } from 'uuid';

export default function () {
  return {
    type: markRaw(SinglePicSelect),
    name: 'single-pic-select',
    id: uuidv4(),
    // 组件的状态:组件的每一个能够修改的状态都应该对应一个编辑组件
    status: {
      title: {
        id: uuidv4(),
        status: '图片单选标题!!!',
        isShow: true,
        name: 'title-editor',
        editCom: markRaw(TitleEditor),
      },
      desc: {
        id: uuidv4(),
        status: '图片单选描述~~~',
        isShow: true,
        name: 'desc-editor',
        editCom: markRaw(DescEditor),
      },
      options: {
        id: uuidv4(),
        status: [
          {
            picTitle: '图片标题1',
            picDesc: '图片描述1',
            value: '',
          },
          {
            picTitle: '图片标题2',
            picDesc: '图片描述2',
            value: '',
          },
        ],
        currentStatus: 0,
        isShow: true,
        name: 'pic-options-editor',
        editCom: markRaw(PicOptionsEditor),
      },
      position: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['左对齐', '居中对齐'],
        isShow: true,
        name: 'position-editor',
        editCom: markRaw(PositionEditor),
      },
      titleSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['22', '20', '18'],
        isShow: true,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      descSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['16', '14', '12'],
        isShow: true,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      titleWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      descWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      titleItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      descItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      titleColor: {
        id: uuidv4(),
        status: '#000',
        isShow: true,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
      descColor: {
        id: uuidv4(),
        status: '#909399',
        isShow: true,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
    },
  };
}

(3)Layout 组件添加图片单选题修改和获取方法。

views/MaterialsView/Layout.vue:

javascript 复制代码
<template>
  <div class="layout-container flex">
    <!-- 选择具体的业务组件 -->
    <div class="left flex wrap space-between">
      <slot />
    </div>
    <!-- 显示对应的业务组件 -->
    <div class="center">
      <Router-View v-slot="{ Component }">
        <component
          :is="Component"
          :status="store.coms[store.currentMaterialCom].status"
          :serialNum="1"
        />
      </Router-View>
    </div>
    <!-- 编辑面板 -->
    <div class="right">
      <EditPannel :com="currentCom" />
    </div>
  </div>
</template>

<script setup lang="ts">
import EditPannel from '@/components/SurveyComs/EditItems/EditPannel.vue';
import { computed, provide } from 'vue';
import { useMaterialStore } from '@/stores/useMaterial';
import { ElMessage } from 'element-plus';
import type { PicLink } from '@/types';
import { isPicLink } from '@/types';
// 数据仓库
const store = useMaterialStore();
// 获取当前选中组件的状态数据
const currentCom = computed(() => store.coms[store.currentMaterialCom]);

const updateStatus = (configKey: string, payload?: number | string | boolean | object) => {
  // 拿到新的状态数据之后,就应该去修改仓库里面的数据
  switch (configKey) {
    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 (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.setSize(currentCom.value.status[configKey], payload);
      break;
    }
    case 'titleWeight':
    case 'descWeight': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "titleWeight or descWeight". Expected number.');
      }
      store.setWeight(currentCom.value.status[configKey], payload);
      break;
    }
    case 'titleItalic':
    case 'descItalic': {
      if (typeof payload !== 'number') {
        console.error('Invalid payload type for "titleItalic or descItalic". Expected number.');
      }
      store.setItalic(currentCom.value.status[configKey], payload);
      break;
    }
    case 'titleColor':
    case 'descColor': {
      if (typeof payload !== 'string') {
        console.error('Invalid payload type for "titleColor or descColor". Expected string.');
      }
      store.setColor(currentCom.value.status[configKey], payload);
    }
  }
};

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

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

<style scoped lang="scss">
.layout-container {
  width: 100%;
  // Header组件高度50px,h1高度50px,上下margin 20px,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 20px);
  align-items: flex-start;
  border: 1px solid var(--border-color);
  border-top-right-radius: var(--border-radius-lg);
  border-bottom-left-radius: var(--border-radius-lg);
  border-bottom-right-radius: var(--border-radius-lg);
}
.left {
  width: 180px;
  text-align: center;
  align-items: flex-start;
  padding: 20px;
}
.center {
  width: 550px;
  // 多减去的60px是上下的padding,,最后20px是额外多减去一部分,避免贴底
  height: calc(100vh - 100px - 40px - 60px - 20px);
  overflow-y: scroll;
  padding: 30px;
  border-left: 1px solid var(--border-color);
}
.right {
  width: 350px;
  height: calc(100vh - 100px - 40px - 20px);
  overflow-y: scroll;
  border-left: 1px solid var(--border-color);
}
</style>

(4)创建图片项组件 PicItem

components/SurveyComs/Common/PicItem.vue:

javascript 复制代码
<template>
  <div @click.stop>
    <div class="container mb-10">
      <!-- 添加图片 -->
      <div class="top flex justify-content-center align-items-center">
        <el-upload
          class="avatar-uploader"
          action="/api/upload"
          name="image"
          :show-file-list="false"
          :on-success="handleAvatarSuccess"
          :before-upload="beforeAvatarUpload"
        >
          <img v-if="imageUrl" :src="imageUrl" class="avatar" />
          <div v-else>
            <el-icon><Upload /></el-icon>
            添加图片
          </div>
        </el-upload>
      </div>
      <!-- 图片标题和描述 -->
      <div
        class="bottom flex justify-content-center align-items-center flex-direction-column font-weight-500"
      >
        <div class="item">{{ picTitle }}</div>
        <div class="desc mt-5 mb-5">{{ picDesc }}</div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, inject, watch } from 'vue';
import { Upload } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import type { UploadProps } from 'element-plus';
const props = defineProps({
  picTitle: {
    type: String,
    default: '',
  },
  picDesc: {
    type: String,
    default: '',
  },
  value: {
    type: String,
    default: '',
  },
  index: {
    type: Number,
    default: 0,
  },
});
const getLink = inject('getLink');
const imageUrl = ref('');

watch(
  () => props.value,
  async (newVal) => {
    if (newVal) {
      // 说明value里面是有链接的,发送服务器请求获取图片
      const response = await fetch(newVal);
      const blob = await response.blob();
      // 使用 blob 来创建 File 对象
      const file = new File([blob], 'image.jpg', { type: blob.type });
      imageUrl.value = URL.createObjectURL(file);
    } else {
      imageUrl.value = '';
    }
  },
  {
    immediate: true,
  },
);

// 上传成功的回调
const handleAvatarSuccess: UploadProps['onSuccess'] = async (response) => {
  // console.log(response, 'response');
  // imageUrl.value = URL.createObjectURL(uploadFile.raw!);
  if (getLink) {
    getLink({
      index: props.index,
      link: response.imageUrl,
    });
  }
};
// 上传之前的回调
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
  if (rawFile.size / 1024 / 1024 > 2) {
    ElMessage.error('图片大小不要超过2MB!');
    return false;
  }
  return true;
};
</script>

<style scoped lang="scss">
.container {
  width: 200px;
  height: 300px;
  border: 1px solid var(--font-color-lightest);
  border-radius: var(--border-radius-md);
  color: var(--font-color-light);
  > .top {
    width: 100%;
    height: 200px;
    background-color: var(--font-color-lightest);
  }
  > .bottom {
    height: 100px;
    font-size: var(--font-size-lg);
    > .desc {
      font-size: var(--font-size-base);
      color: var(--font-color-light);
    }
  }
}
.avatar {
  width: 200px;
  height: 200px;
  object-fit: contain;
}
</style>

(5)修改 SinglePicSelect,引入 PicItem 组件。

components/SurveyComs/Materials/SelectComs/SinglePicSelect.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="flex wrap">
      <el-radio-group v-model="radioValue" class="flex wrap">
        <el-radio v-for="(item, index) in computedState.options" class="picOption flex mb-15" :value="item.picTitle"
          :key="index">
          <PicItem :key="index" v-bind="{ ...item, index }" />
        </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 PicItem from '@/components/SurveyComs/Common/PicItem.vue';
import type { OptionsStatus } from '@/types';
import {
  getTextStatus,
  getCurrentStatus,
  getStringStatusByCurrentStatus,
  getPicTitleDescStatusArr,
} from '@/utils';
const props = defineProps<{
  serialNum: number;
  status: OptionsStatus;
}>();
const radioValue = ref('');
const computedState = computed(() => ({
  title: getTextStatus(props.status.title),
  desc: getTextStatus(props.status.desc),
  position: getCurrentStatus(props.status.position),
  options: getPicTitleDescStatusArr(props.status.options),
  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),
}));
</script>

<style scoped lang="scss">
.picOption {
  height: auto;
  flex-direction: column-reverse;
}
</style>

此时基本完成了图片单选题组件。下面是相关文件的修改补充。

(6)types/editProps.ts

javascript 复制代码
import type { VueComType } from './common';

export interface BaseProps {
  id: string;
  isShow: boolean;
  name: string;
  editCom: VueComType;
}

export type StringStatusArr = string[];
export type ValueStatusArr = Array<{ value: string; status: string }>;
export type PicTitleDescStatusArr = Array<{
  picTitle: string;
  picDesc: string;
  value: string;
}>;

export interface TextProps extends BaseProps {
  status: string;
}

export type OptionsStatusArr = StringStatusArr | ValueStatusArr | PicTitleDescStatusArr;

export interface OptionsProps extends BaseProps {
  status: OptionsStatusArr;
  currentStatus: number;
}

// 公共的设置项,每个组件都有的设置项
export interface BaseStatus {
  title: TextProps;
  desc: TextProps;
  position: OptionsProps;
  titleSize: OptionsProps;
  descSize: OptionsProps;
  titleWeight: OptionsProps;
  descWeight: OptionsProps;
  titleItalic: OptionsProps;
  descItalic: OptionsProps;
  titleColor: TextProps;
  descColor: TextProps;
}

// 因为不是所有业务组件都有 options 这个设置项,所以需要分开定义
export interface OptionsStatus extends BaseStatus {
  options: OptionsProps;
}

// 确定 status 是字符串数组
export function isStringArray(status: OptionsStatusArr): status is string[] {
  return Array.isArray(status) && typeof status[0] === 'string';
}

// 确定 status 是 { value: string; status: string } 这种类型的数组
export function isValueStatusArr(status: OptionsStatusArr): status is ValueStatusArr {
  return (
    Array.isArray(status) &&
    typeof status[0] === 'object' &&
    'value' in status[0] &&
    'status' in status[0]
  );
}

// 确定 status 是 { picTitle: string; picDesc: string; value: string } 这种类型的数组
export function isPicTitleDescStatusArr(status: OptionsStatusArr): status is PicTitleDescStatusArr {
  return (
    Array.isArray(status) &&
    typeof status[0] === 'object' &&
    'picTitle' in status[0] &&
    'picDesc' in status[0] &&
    'value' in status[0]
  );
}

export type PicLink = { link: string; index: number };
export function isPicLink(obj: object): obj is PicLink {
  return 'link' in obj && 'index' in obj;
}

(7)utils/index.ts:

javascript 复制代码
// 工具库
import type { TextProps, OptionsProps } from '@/types';
import { isStringArray, isPicTitleDescStatusArr } from '@/types';

export function getTextStatus(props: TextProps) {
  return props.status;
}

export function getStringStatus(props: OptionsProps) {
  if (props && isStringArray(props.status)) {
    return props.status;
  }
}

export function getPicTitleDescStatusArr(props: OptionsProps) {
  if (props && isPicTitleDescStatusArr(props.status)) {
    return props.status;
  }
}

export function getCurrentStatus(props: OptionsProps) {
  return props.currentStatus;
}

export function getStringStatusByCurrentStatus(props: OptionsProps) {
  if (props && isStringArray(props.status)) {
    return props.status[props.currentStatus];
  }
}

(8)stores/actions.ts

javascript 复制代码
import type { TextProps, OptionsProps, PicLink } from '@/types';
import { isStringArray, isPicTitleDescStatusArr } from '@/types';
export function setTextStatus(textProps: TextProps, text: string) {
  textProps.status = text;
}

export function addOption(optionProps: OptionsProps) {
  if (isStringArray(optionProps.status)) {
    optionProps.status.push('新选项');
  } else if (isPicTitleDescStatusArr(optionProps.status)) {
    optionProps.status.push({ picTitle: '图片标题', picDesc: '图片描述', value: '' });
  }
}

export function removeOption(optionProps: OptionsProps, index: number) {
  if (optionProps.status.length === 2) {
    return false;
  }
  optionProps.status.splice(index, 1);
  return true;
}

export function setPosition(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}

export function setSize(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}

export function setWeight(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}
export function setItalic(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}

export function setColor(textProps: TextProps, text: string) {
  textProps.status = text;
}

export function setPicLinkByIndex(optionProps: OptionsProps, payload: PicLink) {
  console.log(optionProps, 'optionPropsoptionPropsoptionProps');
  console.log(payload, 'payloadpayloadpayload');
  if (isPicTitleDescStatusArr(optionProps.status)) {
    console.log('first');
    optionProps.status[payload.index].value = payload.link;
  }
}

(9)stores/useMaterial.ts:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setSize,
  setWeight,
  setItalic,
  setColor,
  setPicLinkByIndex,
} from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
      'single-pic-select': defaultStatusMap['single-pic-select'](),
    },
  }),
  actions: {
    setCurrentMaterialCom(comName: string) {
      this.currentMaterialCom = comName;
    },
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setSize,
    setWeight,
    setItalic,
    setColor,
    setPicLinkByIndex,
  },
});

7.4 完成右侧图片编辑功能

components/SurveyComs/EditItems/PicOptionsEditor.vue:

javascript 复制代码
<template>
  <div>
    <div class="flex align-items-center">
      <div class="mr-10">题目选项</div>
      <el-button size="small" :icon="Plus" circle @click="addOptionHandle" />
    </div>
    <div v-for="(item, index) in textArr" :key="index">
      <!-- 选项 -->
      <div class="title mt-10 mb-10 flex align-items-center">
        <span>选项{{ index + 1 }}</span>
        <el-button
          type="danger"
          class="ml-5 delete"
          size="small"
          :icon="Minus"
          circle
          @click="removeOptionHandle(index)"
        />
      </div>
      <!-- 是否上传图片 -->
      <div class="mb-5">
        <div v-if="item.value" class="flex">
          <span class="title mr-5">已上传图片</span>
          <el-link type="primary" @click="deletePic(index)">删除图片</el-link>
        </div>
        <span v-else class="title">未上传图片</span>
      </div>
      <!-- 修改图片标题 -->
      <el-input class="mb-5" v-model="item.picTitle" placeholder="图片标题" />
      <!-- 修改图片描述 -->
      <el-input type="textarea" :rows="3" placeholder="图片描述" v-model="item.picDesc" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, inject } from 'vue';
import { Plus, Minus } from '@element-plus/icons-vue';
import type { VueComType, PicTitleDescStatusArr } from '@/types';
import { ElMessage, ElMessageBox } from 'element-plus';
const props = defineProps<{
  currentStatus: number;
  status: PicTitleDescStatusArr;
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
  id: string;
}>();
const textArr = ref(props.status);
const updateStatus = inject('updateStatus');
const addOptionHandle = () => {
  if (updateStatus) {
    updateStatus(props.configKey);
  }
};
const removeOptionHandle = (index: number) => {
  if (updateStatus) {
    updateStatus(props.configKey, index);
  }
};
const deletePic = (index: number) => {
  ElMessageBox.confirm('是否确认删除已上传的图片?', '警告', {
    confirmButtonText: '确认',
    cancelButtonText: '取消',
    type: 'warning',
  })
    .then(() => {
      // 确认删除
      if (updateStatus) {
        updateStatus(props.configKey, {
          link: '',
          index,
        });
      }
    })
    .catch(() => {
      // 取消删除
      ElMessage.info('已取消删除');
    });
};
</script>

<style scoped lang="scss">
.title {
  color: var(--font-color-light);
  font-size: var(--font-size-base);
}
.delete {
  transform: scale(0.8);
}
</style>

7.5 修复警告

7.5.1 inject 的方法,TypeScript 无法识别是否为方法


updateStatus 和 getLink 的问题,都在于通过 injectLayout.vue h获取时,TS 无法推断其类型。

解决方案:在 types/editProps.ts 中加入对应类型。并且在相关文件中使用。

(1)types/editProps.ts,关键代码:

javascript 复制代码
export type GetLink = (obj: PicLink) => void;

export type UpdateStatus = (
  ConfigKey: string,
  payload?: number | string | boolean | PicLink
) => void;

(2)全局搜索 const updateStatus = inject('updateStatus'),在对应文件加入 import type { UpdateStatus } from '@/types'; 类型引入,并且将其替换为 const updateStatus = inject<UpdateStatus>('updateStatus')

(3)getLink 解决方式同理

7.5.2 修复 《不能调用可能是"未定义"的对象》 问题

javascript 复制代码
function inputHandle(newVal: string) {
  updateStatus(props.configKey, newVal);
}

修改为

javascript 复制代码
function inputHandle(newVal: string) {
  if(updateStatus) {
    updateStatus(props.configKey, newVal);
  }
}

即可,添加下方法是否存在的判断。

7.5.3 给数据仓库添加类型


(1)添加 types/store.ts:

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

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

export interface Actions {
  setTextStatus: (textProps: TextProps, text: string) => void;
  addOption: (optionProps: OptionsProps) => void;
  removeOption: (optionProps: OptionsProps, index: number) => number;
  setPosition: (optionProps: OptionsProps, index: number) => void;
  setSize: (optionProps: OptionsProps, index: number) => void;
  setWeight: (optionProps: OptionsProps, index: number) => void;
  setItalic: (optionProps: OptionsProps, index: number) => void;
  setColor: (optionProps: TextProps, index: string) => void;
  setCurrentStatus: (optionProps: OptionsProps, index: number) => void;
  setPicLinkByIndex: (optionProps: OptionsProps, payload: PicLink) => void;
}

// 仓库状态
export interface MaterialStore extends Actions {
  currentMaterialCom: Material;
  coms: Record<Material, Status>;
  setCurrentSurveyCom: (com: Material) => void;
}

(2)types/index.ts 中加入:

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

(3)添加类型。views/MaterialsView/Layout.vue 关键代码:

javascript 复制代码
import type { PicLink, MaterialStore } from '@/types';
// 其他代码省略
const store = useMaterialStore() as unknown as MaterialStore;

7.5.4 修复《不能将类型"undefined"分配给类型"string"》问题

关键代码:

javascript 复制代码
if (typeof payload !== 'string') {
 console.error('Invalid payload type for "title or desc". Expected string.');
}
store.setTextStatus(currentCom.value.status[configKey], payload);

if 语句加个 return 即可修复:

javascript 复制代码
if (typeof payload !== 'string') {
 console.error('Invalid payload type for "title or desc". Expected string.');
 return;
}
store.setTextStatus(currentCom.value.status[configKey], payload);

7.5.5 其他

可能还存在一些其他的警告,大家检查下信息,处理方法和上面相差不大,自行解决下即可。

8. 封装备注说明组件

8.1 添加备注说明组件

(1)router/index.ts 添加对应路由,关键代码:

javascript 复制代码
{
  path: '/note-group',
  name: 'note-group',
  component: () => import('@/views/MaterialsView/NoteGroupView.vue'),
  redirect: '/text-note',
  children: [
    {
      path: '/text-note',
      name: 'text-note',
      component: () =>
        import('@/components/SurveyComs/Materials/NoteComs/TextNote.vue'),
    },
  ]
},

(2)修改 views/MaterialsView/NoteGroupView.vue:

javascript 复制代码
<template>
  <Layout>
    <Router-link class="link-item mb-15" exact-active-class="link-item-active" to="/text-note">备注说明</Router-link>
  </Layout>
</template>

<script setup lang="ts">
import Layout from './Layout.vue';
</script>

<style scoped></style>

8.2 创建物料组件

components/SurveyComs/Materials/NoteComs/TextNote.vue:

javascript 复制代码
<template>
  <div>
    备注说明组件
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

8.3 创建编辑项组件

components/SurveyComs/EditItems/TextTypeEditor.vue:

javascript 复制代码
<template>
  <div>
    备注说明类型编辑器
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped>

</style>

8.4 创建数据仓库

(1)创建备注说明的数据仓库配置文件。

configs/defaultStatus/TextNote.ts:

javascript 复制代码
// 备注说明基础状态
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 { markRaw } from 'vue';
import { v4 as uuidv4 } from 'uuid';

export default function () {
  return {
    type: markRaw(TextNote),
    name: 'text-note',
    id: uuidv4(),
    // 组件的状态:组件的每一个能够修改的状态都应该对应一个编辑组件
    status: {
      // 多一个type编辑项
      type: {
        id: uuidv4(),
        status: ['标题', '描述'],
        currentStatus: 1,
        isShow: true,
        name: 'text-type-editor',
        editCom: markRaw(TextTypeEditor),
      },
      title: {
        id: uuidv4(),
        status: '备注说明标题',
        isShow: false,
        name: 'title-editor',
        editCom: markRaw(TitleEditor),
      },
      desc: {
        id: uuidv4(),
        status: '备注说明描述',
        isShow: true,
        name: 'desc-editor',
        editCom: markRaw(DescEditor),
      },
      position: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['左对齐', '居中对齐'],
        isShow: true,
        name: 'position-editor',
        editCom: markRaw(PositionEditor),
      },
      titleSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['22', '20', '18'],
        isShow: false,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      descSize: {
        id: uuidv4(),
        currentStatus: 0,
        status: ['16', '14', '12'],
        isShow: true,
        name: 'size-editor',
        editCom: markRaw(SizeEditor),
      },
      titleWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: false,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      descWeight: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['加粗', '正常'],
        isShow: true,
        name: 'weight-editor',
        editCom: markRaw(WeightEditor),
      },
      titleItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: false,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      descItalic: {
        id: uuidv4(),
        currentStatus: 1,
        status: ['斜体', '正常'],
        isShow: true,
        name: 'italic-editor',
        editCom: markRaw(ItalicEditor),
      },
      titleColor: {
        id: uuidv4(),
        status: '#000',
        isShow: false,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
      descColor: {
        id: uuidv4(),
        status: '#909399',
        isShow: true,
        name: 'color-editor',
        editCom: markRaw(ColorEditor),
      },
    },
  };
}

(2)记录对应配置的映射。

configs/defaultStatus/defaultStatusMap.ts:

javascript 复制代码
// 该文件用于定义默认状态的映射表
import singleSelectDefaultStatus from './SingleSelect';
import singlePicSelectDefaultStatus from './SinglePicSelect';
import textNote from './TextNote';

export const defaultStatusMap = {
  'single-select': singleSelectDefaultStatus,
  'single-pic-select': singlePicSelectDefaultStatus,
  'text-note': textNote,
};

(3)数据仓库记录对应组件初始化配置。

修改 stores/useMaterial.ts,关键代码:

javascript 复制代码
state: () => ({
  currentMaterialCom: 'single-select', // 当前选中的组件
  // 记录所有的业务组件
  coms: {
    'single-select': defaultStatusMap['single-select'](),
    'single-pic-select': defaultStatusMap['single-pic-select'](),
    'text-note': defaultStatusMap['text-note'](),
  },
}),

8.5 添加对应 TS 类型(到这步,页面基本出来)

(1)types/editProps.ts 添加 TypeStatus,关键代码:

javascript 复制代码
// 公共的设置项,每个组件都有的设置项
export interface BaseStatus {
  title: TextProps;
  desc: TextProps;
  position: OptionsProps;
  titleSize: OptionsProps;
  descSize: OptionsProps;
  titleWeight: OptionsProps;
  descWeight: OptionsProps;
  titleItalic: OptionsProps;
  descItalic: OptionsProps;
  titleColor: TextProps;
  descColor: TextProps;
}

// 因为不是所有业务组件都有 options 这个设置项,所以需要分开定义
export interface OptionsStatus extends BaseStatus {
  options: OptionsProps;
}

// 以下是添加的类型,其他代码省略
export interface TypeStatus extends BaseStatus {
  type: OptionsProps;
}

export function IsOptionsStatus(status: BaseStatus): status is OptionsStatus {
  return 'options' in status;
}

export function IsTypeStatus(status: BaseStatus): status is TypeStatus {
  return 'type' in status;
}

(2)types\store.ts 添加对应业务组件类型,关键代码:

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

8.6 完成业务组件(关联数据仓库)

components/SurveyComs/Materials/NoteComs/TextNote.vue

javascript 复制代码
<template>
  <h1
    v-if="computedState.type === 0"
    class="pt-10 pb-10 text-center font-weight-200"
    :class="{
      'font-italic': !computedState.titleItalic,
      'font-bold': !computedState.titleWeight,
    }"
    :style="{
      fontSize: computedState.titleSize + 'px',
      color: computedState.titleColor,
    }"
  >
    {{ computedState.title }}
  </h1>
  <p
    v-else
    :class="{
      'text-center': computedState.position,
      'font-italic': !computedState.descItalic,
      'font-bold': !computedState.descWeight,
    }"
    :style="{
      fontSize: computedState.descSize + 'px',
      color: computedState.descColor,
    }"
  >
    {{ computedState.desc }}
  </p>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { TypeStatus } from '@/types';
import { getTextStatus, getCurrentStatus, getStringStatusByCurrentStatus } from '@/utils';
const props = defineProps<{
  serialNum: number;
  status: TypeStatus;
}>();
const computedState = computed(() => ({
  type: getCurrentStatus(props.status.type),
  title: getTextStatus(props.status.title),
  desc: getTextStatus(props.status.desc),
  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),
}));
</script>

<style scoped></style>

8.7 完成编辑面板(关联数据仓库)

(1)添加切换说明类型时的设置展示方法changeEditorIsShowStatus。

utils/index.ts:

javascript 复制代码
// 工具库
import type { TextProps, OptionsProps, TypeStatus } from '@/types';
import { isStringArray, isPicTitleDescStatusArr } from '@/types';

export function getTextStatus(props: TextProps) {
  return props.status;
}

export function getStringStatus(props: OptionsProps) {
  if (props && isStringArray(props.status)) {
    return props.status;
  }
}

export function getPicTitleDescStatusArr(props: OptionsProps) {
  if (props && isPicTitleDescStatusArr(props.status)) {
    return props.status;
  }
}

export function getCurrentStatus(props: OptionsProps) {
  return props.currentStatus;
}

export function getStringStatusByCurrentStatus(props: OptionsProps) {
  if (props && isStringArray(props.status)) {
    return props.status[props.currentStatus];
  }
}

export function changeEditorIsShowStatus(status: TypeStatus, type: number) {
  if (type !== status.type.currentStatus) {
    status.title.isShow = !status.title.isShow;
    status.desc.isShow = !status.desc.isShow;
    status.position.isShow = !status.position.isShow;
    status.titleSize.isShow = !status.titleSize.isShow;
    status.descSize.isShow = !status.descSize.isShow;
    status.titleWeight.isShow = !status.titleWeight.isShow;
    status.descWeight.isShow = !status.descWeight.isShow;
    status.titleItalic.isShow = !status.titleItalic.isShow;
    status.descItalic.isShow = !status.descItalic.isShow;
    status.titleColor.isShow = !status.titleColor.isShow;
    status.descColor.isShow = !status.descColor.isShow;
  }
}

(2)添加通用设置当前状态方法 setCurrentStatus

stores/actions.ts 关键代码:

javascript 复制代码
export function setCurrentStatus(optionProps: OptionsProps, index: number) {
  optionProps.currentStatus = index;
}

(3)stores/useMaterial.ts 引入:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setSize,
  setWeight,
  setItalic,
  setColor,
  setCurrentStatus,
  setPicLinkByIndex,
} from './actions';

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
      'single-pic-select': defaultStatusMap['single-pic-select'](),
      'text-note': defaultStatusMap['text-note'](),
    },
  }),
  actions: {
    setCurrentMaterialCom(comName: string) {
      this.currentMaterialCom = comName;
    },
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setSize,
    setWeight,
    setItalic,
    setColor,
    setCurrentStatus,
    setPicLinkByIndex,
  },
});

(4)Layout 组件中加入 updateStatus 对应切换代码。

views/MaterialsView/Layout.vue 关键代码:

javascript 复制代码
case 'type': {
  if (typeof payload === 'number' && IsTypeStatus(currentCom.value.status)) {
    // 切换其他编辑器的显示状态
    changeEditorIsShowStatus(currentCom.value.status, payload);
    store.setCurrentStatus(currentCom.value.status[configKey], payload);
  }
  break;
}
javascript 复制代码
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;
}

(5)修改 说明类型编辑项 TextTypeEditor 组件。

components/SurveyComs/EditItems/TextTypeEditor.vue:

javascript 复制代码
<template>
  <ButtonGroup title="说明类型" :status="status[currentStatus]">
    <el-button-group>
      <el-button
        :class="{
          select: currentStatus === 0,
        }"
        @click="changeType(0)"
      >
        <font-awesome-icon icon="heading" />
      </el-button>
      <el-button
        :class="{
          select: currentStatus === 1,
        }"
        @click="changeType(1)"
      >
        <font-awesome-icon icon="paragraph" />
      </el-button>
    </el-button-group>
  </ButtonGroup>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import ButtonGroup from './ButtonGroup.vue';
import type { VueComType, UpdateStatus } from '@/types';
const props = defineProps<{
  currentStatus: number;
  status: string[];
  isShow: boolean;
  configKey: string;
  editCom: VueComType;
}>();
const updateStatus = inject<UpdateStatus>('updateStatus');
const changeType = (type: number) => {
  if (updateStatus) updateStatus(props.configKey, type);
};
</script>

<style scoped></style>


9. 封装预设组件


其实预设组件,大多数情况就是直接使用其他组件或者进行组件组合,然后修改默认数据进行改造而已。

9.1 初始化预设组件(性别和学历组件为例)

(1)改造 views/MaterialsView/PersonalInfoGroupView.vue

javascript 复制代码
<template>
  <Layout>
    <Router-link class="link-item mb-15" exact-active-class="link-item-active" to="/personal-info-gender">性别</Router-link>
    <Router-link class="link-item mb-15" exact-active-class="link-item-active" to="/personal-info-education">学历</Router-link>
  </Layout>
</template>

<script setup lang="ts">
import Layout from './Layout.vue';
</script>

<style scoped></style>

(2)添加对应路由,router\index.ts 关键代码:

javascript 复制代码
{
  path: '/personal-info-group',
  name: 'personal-info-group',
  component: () => import('@/views/MaterialsView/PersonalInfoGroupView.vue'),
  redirect: 'personal-info-gender',
  children: [
    {
      path: '/personal-info-gender',
      name: 'personal-info-gender',
      component: () =>
        import('@/components/SurveyComs/Materials/SelectComs/SingleSelect.vue'),
    },
    {
      path: '/personal-info-education',
      name: 'personal-info-education',
      component: () =>
        import('@/components/SurveyComs/Materials/SelectComs/SingleSelect.vue'),
    },
  ],
},

性别和学历组件都是直接复用单选题组件。

(3)添加组件和初始状态的映射。

configs/defaultStatus/defaultStatusMap.ts

javascript 复制代码
// 该文件用于定义默认状态的映射表
import singleSelectDefaultStatus from './SingleSelect';
import singlePicSelectDefaultStatus from './SinglePicSelect';
import textNote from './TextNote';

export const defaultStatusMap = {
  'single-select': singleSelectDefaultStatus,
  'single-pic-select': singlePicSelectDefaultStatus,
  'text-note': textNote,
  'personal-info-gender': singleSelectDefaultStatus,
  'personal-info-education': singleSelectDefaultStatus,
};

数据虽然是新的实例,但是目前和单选题组件用的默认数据是一致的。当然,后续会进行初始化时的改造。

(4)数据仓库添加对应业务组件。stores\useMaterial.ts 关键代码:

javascript 复制代码
state: () => ({
  currentMaterialCom: 'single-select', // 当前选中的组件
  // 记录所有的业务组件
  coms: {
    'single-select': defaultStatusMap['single-select'](),
    'single-pic-select': defaultStatusMap['single-pic-select'](),
    'text-note': defaultStatusMap['text-note'](),
    'personal-info-gender': defaultStatusMap['personal-info-gender'](),
    'personal-info-education': defaultStatusMap['personal-info-education'](),
  },
}),

(5)类型文件添加对应题目类型 types\store.ts 关键代码:

javascript 复制代码
// 题目类型
export type SurveyComName =
  | 'single-select'
  | 'single-pic-select'
  | 'personal-info-gender'
  | 'personal-info-education';

9.2 初始化预设组件数据

(1)创建 configs/defaultStatus/initStatus.ts,存储预设组件的初始化数据:

javascript 复制代码
// 专门导出各种初始值
export const genderStatus = () => ['男', '女', '保密'];

export const educationStatus = () => [
  '初中及以下',
  '高中/中专/技校',
  '大学专科',
  '大学本科',
  '硕士及以上',
];

export const careerStatus = () => [
  '在校学生',
  '政府/机关干部/公务员',
  '企业管理者(包括基层及中高层管理者)',
  '专业人员(如医生/律师/文体/记者/老师等)',
  '普通职员(办公室/写字楼工作人员)',
  '普通工人(如工厂工人/体力劳动者等)',
  '商业服务业职工(如销售人员/商店职员/服务员等)',
  '个体经营者/承包商',
  '自由职业者',
  '农林牧渔劳动者',
  '退休',
  '暂无职业',
  '其他',
];

(2)utils/index.ts 添加数据仓库对应业务组件的数据初始化方法,关键代码:

javascript 复制代码
// 工具库
import type { TextProps, OptionsProps, TypeStatus, Status, Material } from '@/types';
import { isStringArray, isPicTitleDescStatusArr, IsOptionsStatus } from '@/types';
import { genderStatus, educationStatus } from '@/configs/defaultStatus/initStatus';

export function updateInitStatusBeforeAdd(comStatus: Status, newMaterialName: Material) {
  switch (newMaterialName) {
    case 'personal-info-gender': {
      comStatus.name = 'personal-info-gender';
      comStatus.status.title.status = '您的性别是?';
      if(IsOptionsStatus(comStatus.status)) {
        comStatus.status.options.status = genderStatus();
      }
      break;
    }
    case 'personal-info-education': {
      comStatus.name = 'personal-info-gender';
      comStatus.status.title.status = '您的学历是?';
      if(IsOptionsStatus(comStatus.status)) {
        comStatus.status.options.status = educationStatus();
      }
      break;
    }
  }
}

(3)修改数据仓库中性别和学历组件的初始化数据。stores\useMaterial.ts:

javascript 复制代码
// 组件市场里面所有组件状态的仓库
import { defineStore } from 'pinia';
import { defaultStatusMap } from '@/configs/defaultStatus/defaultStatusMap';
import {
  setTextStatus,
  addOption,
  removeOption,
  setPosition,
  setSize,
  setWeight,
  setItalic,
  setColor,
  setCurrentStatus,
  setPicLinkByIndex,
} from './actions';
import { updateInitStatusBeforeAdd } from '@/utils';
import type { Status, Material } from '@/types';

// 哪些组件需要初始化
const keyToInit = ['personal-info-gender', 'personal-info-education'] as Material[];

const initializeStates: { [key: string]: Status } = {};

keyToInit.forEach(key => {
  const defaultStatus = defaultStatusMap[key]() as Status;
  updateInitStatusBeforeAdd(defaultStatus, key);
  initializeStates[key] = defaultStatus;
})

export const useMaterialStore = defineStore('materialStore', {
  state: () => ({
    currentMaterialCom: 'single-select', // 当前选中的组件
    // 记录所有的业务组件
    coms: {
      'single-select': defaultStatusMap['single-select'](),
      'single-pic-select': defaultStatusMap['single-pic-select'](),
      'text-note': defaultStatusMap['text-note'](),
      'personal-info-gender': initializeStates['personal-info-gender'],
      'personal-info-education': initializeStates['personal-info-education'],
    },
  }),
  actions: {
    setCurrentMaterialCom(comName: string) {
      this.currentMaterialCom = comName;
    },
    setTextStatus,
    addOption,
    removeOption,
    setPosition,
    setSize,
    setWeight,
    setItalic,
    setColor,
    setCurrentStatus,
    setPicLinkByIndex,
  },
});


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

相关推荐
Hao_Harrision2 小时前
50天50个小项目 (React19 + Tailwindcss V4) ✨ | AutoTextEffect(自动打字机)
前端·typescript·react·tailwindcss·vite7
Sheldon一蓑烟雨任平生2 小时前
Vue3 低代码平台项目实战(下)
低代码·typescript·vue3·低代码平台·json schema·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