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 低代码平台项目实战(下)

相关推荐
jonjia9 小时前
模块、脚本与声明文件
typescript
jonjia9 小时前
配置 TypeScript
typescript
jonjia9 小时前
TypeScript 工具函数开发
typescript
jonjia9 小时前
注解与断言
typescript
jonjia9 小时前
IDE 超能力
typescript
jonjia9 小时前
对象类型
typescript
jonjia9 小时前
快速搭建 TypeScript 开发环境
typescript
jonjia9 小时前
TypeScript 的奇怪之处
typescript
jonjia9 小时前
类型派生
typescript
jonjia9 小时前
开发流程中的 TypeScript
typescript