低代码平台项目实战(上)
- [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)有一些报错。

从信息上,可以看到 titleSize 和 descSize 两个字段,我们在组件中定义了对应的 prop 为 string 类型。
但是报错信息告诉我们,这两个字段实际上可能 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 Comment 为 true,并且修改 distFileName 为 PROJECTTREE.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 的问题,都在于通过 inject 从 Layout.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 低代码平台项目实战(下)》