前段时间在开发企业后台管理系统时接到一个需求,需求大致就是做一个互动课程,类似一个思维导图,每一个节点都可以关联一个视频课程,节点可以设置名称和关联的视频名称,当视频观看完根据用户所选择的节点播放不同的节点视频,我先放一张图片和一段视频可能大家更容易明白。
vue开发思维导图,节点添加视频课程,根据剧情播放视频
我大概梳理下需求要实现的功能:
- 左侧素材上传区域,实现可以将视频批量上传到阿里云OSS,上传过程中实时显示上传进度以及上传转态,可以对上传队列中的视频进行管理。
- 中间的画布区域是剧情的管理区域,类似思维导图,可对画布和当前节点进行操作:
- 可为当前节点添加子节点,节点层级没有限制可无限添加,节点的名称可以修改,节点右键可选择删除。
- 支持素材拖动至节点时,节点自动关联该视频,并使用视频的第一帧作为封面展示
- 画布支持拖动,并可以按照一定比例进行放大缩小方便对剧情树的操作,画布支持一键归位功能。
3. 右侧为节点的详细编辑区域,在画布中点击节点,弹出编辑层,可以设置当前节点以及关联素材的名称,可以重新选择当前节点管理的素材。同时也可以编辑当前节点的子节点的名称,以及拖动子节点进行顺序。
难点分析:在开发过程中左侧的视频批量上传功能是上传阿里云OSS,这个没什么难度,有一定难度的是中间的思维导图剧情数编辑。由于是能支持无限层级结构所以我们数据结构是NodeList数据结构
这就要求我们我们封装一个自引用组件,只需要给NodeList Data 就可以将思维导图渲染出来。
封装画布组件
<content-map
ref="contentMap"
class="content-map"
:data="dataList"
:checking="checking"
:zoomVal="zoomVal"
@hideTransiton="hideTransiton"
@showMenuCom="showMenuCom"
@addOnePlot="addOnePlot"
@mouseenterIn="mouseenterIn"
@mouseenterOut="mouseenterOut"
@clickPlot="clickPlot"
@delOneModel="delOneModel"
@checkingModel="checkModel"
@clickPlotTitle="clickPlotTitle"
/>
content-map.vue
<template>
<div class="my-chart " @click="showDelMemu = false">
<div
class="content-item"
:class="{
hasChildrens: data.Nodes && data.Nodes.length > 1,
pl1: data.ParentID == 0,
checkDisable: data.checkDisable,
isJump: data.Type == 1,
}"
@mouseenter="mouseenter(data)"
@mouseleave="mouseleave"
@contextmenu.prevent="openMenu($event, data)"
>
<p
class="title one-hang"
v-if="data.ParentID != 0"
@click="clickTitle(data)"
>
<!-- <span class="optionAD">{{ optionAD[index] }}:</span> -->
<span class="one-hang data-name">{{
data.OptionName ? _setOptionName(data.OptionName) : "点此编辑选项"
}}</span>
</p>
<div class="content-left-all" @click.stop="clickItem($event, data)">
<div class="content-left" v-if="data.CoverURL && data.PlotVideoID != 0">
<img :src="data.CoverURL" />
</div>
<div class="tag-name one-hang">
<el-tag
size="small"
:type="data.JumpPlotModuleID ? '' : 'info'"
v-if="data.Type == 1"
class="tage isJump one-hang"
effect="dark"
>
{{
data.ModuleName ? `跳转至:${data.ModuleName}` : "点击设置跳转内容"
}}
</el-tag>
<el-tag
size="small"
v-if="data.Type == 2"
class="tage"
effect="dark"
type="danger"
>结局</el-tag
>
<span
v-if="data.Type != 1"
class="course-name"
:class="{ lh60: data.Type == 0 }"
>{{ data.ModuleName ? data.ModuleName : "拖拽素材至此" }}</span
>
</div>
</div>
<p class="icon-wrapper" v-if="data.Type == 0">
<el-popover
placement="right"
v-model="plotVisible"
:disabled="data.Nodes && data.Nodes.length >= 4"
>
<el-button-group class="">
<el-button
plain
size="small"
@click="addPlot(data, 0)"
icon="el-icon-video-camera-solid"
>创建剧情模块</el-button
>
<el-button plain size="small" @click="addPlot(data, 1)"
><i class="el-icon-s-promotion"></i>创建跳转模块</el-button
>
</el-button-group>
<el-button
size="mini"
circle
slot="reference"
:icon="
data.Nodes && data.Nodes.length > 1
? 'el-icon-share'
: 'el-icon-plus'
"
:type="data.Nodes && data.Nodes.length > 1 ? 'primary' : ''"
@click.stop="showMenu(data)"
></el-button>
</el-popover>
</p>
</div>
<transition-group
tag="ul"
name="list"
class="my-chart-con"
v-if="data.Nodes"
>
<li
class="list-item"
v-for="(item, index) in data.Nodes"
:key="item.PlotModuleID"
:class="{ onlyOne: data.Nodes.length == 1 }"
>
<content-item
:data="item"
:index="index"
v-bind="$attrs"
v-on="$listeners"
@showMenuCom="openMenu"
/>
</li>
</transition-group>
</div>
</template>
<script>
/**
* @description 内容组件
* @author lee TODO:注意本组件为循环套嵌组件,当子组件层大于2级时事件无法正常传递,解决方案在中间层组件上加上 v-bind="$attrs" v-on="$listeners" ,父级正常监听
*/
import { BaseTip } from "@/api/baseConfig";
import _ from "lodash";
export default {
name: "content-item",
components: {},
props: {
data: {
type: Object,
defalut: {},
},
index: {
type: Number,
default: 0,
},
drag: { type: Boolean, defalut: false },
checking: {
type: Boolean,
default: false,
},
zoomVal: {
type: Number,
default: 0,
},
},
data() {
return {
optionAD: ["A", "B", "C", "D"],
plotVisible: false,
showDelMemu: false,
styleMemu: {},
Type: "",
showDel: true,
};
},
watch: {},
computed: {},
methods: {
_setOptionName(name) {
return _.truncate(name, {
length: 16,
});
},
showMenu(data) {
if (data.Nodes.length > 3) {
BaseTip(0, "一个模块下最多创建4个子模块!");
this.plotVisible = false;
this.visible = false;
return;
}
this.visible = false;
},
/**
* @description 在item上鼠标右键
* @param {Object} e 事件对象
* @param {Object} data 当前数据
* @param {Boolean} isClick 是否是点击
*/
openMenu(e, data, isClick = false) {
if (this.checking) return;
this.$emit("hideTransiton");
if (this.zoomVal != 0) return;
this.showDel = data.ParentID != 0;
if (isClick) {
this.showDel = false;
}
this.visible = false;
Object.assign(this.styleMemu, {
top: `${e.pageY - e.view.scrollY}px`,
left: `${e.pageX}px`,
});
console.log();
this.Type = data.Type;
this.showDelMemu = true;
this.$emit("showMenuCom", e, data);
},
// 点击了选择跳转模块
checkModel() {
this.showDelMemu = false;
this.$emit("checkingModel");
},
// 鼠标移入事件
mouseenter(item) {
this.$emit("mouseenterIn", item);
},
// 鼠标移出事件
mouseleave() {
this.$emit("mouseenterOut", false);
},
// 点击外层div一个剧情
clickItem(e, item) {
if (item.Type == 1) {
// 跳转模块点击要显示选择跳转得模块 不显示删除
this.openMenu(e, item, true);
return;
}
this.showDelMemu = false;
this.$forceUpdate();
this.$emit("clickPlot", item);
},
// 点击了剧情的title 需要修改上级
clickTitle(item) {
this.$emit("clickPlotTitle", item);
},
// 点击组件的一个剧情
clickPlotItemCon() {
this.$emit("clickPlot");
},
/**
* @description 添加一个素材
* @param {Object} item 要添加的对象
* @param {Number} type 1 创建剧情模块 2 创建跳转模块
*/
addPlot(item, type) {
this.plotVisible = false;
this.$emit("addOnePlot", item, type);
},
/**
* @description 删除一个素材
* @param {Number} type 1 清空模块 2 删除模块
*/
delModel(type) {
this.showDelMemu = false;
const tip =
type == 1
? "改操作将删除该素材以及对应剧情中的视频内容,是否继续?"
: "改操作将删除所有素材与剧情树,是否继续?";
this.$confirm(tip, type == 1 ? "清空" : "删除素材", {
type: "warning",
}).then((res) => {
this.$emit("delOneModel", type);
});
},
// 将数据排序
sortList(item) {
// console.log("sort----------------", item);
},
},
created() {},
mounted() {
document.addEventListener("scroll", () => {
this.showDelMemu = false;
});
},
};
</script>
至此用vue 开发类似一套流程图的功能就开发完了,于此配套使用的是2C的移动端,在移动端呈现的效果是这样的:
当当前视频播放完毕后就会显示当前节点的子节点,选择不同的节点就播放不同的关联视频,这样这个流程就可以一直走下去了。