效果
别的不说先上效果
难点
- 树的实现 :需要递归自身,有丶难度。但是对于各位应该是
有手就彳亍
。 - 双亲节点样式 :可以观察到双亲节点在连接线左侧是有内容的,叶子节点则没有。
- 连接线:可以观察到双亲节点是实心圆点,叶子节点是空心圆圈且连接线有缩进。
- 确定层级:缩进是根据层级确定的,如何确定层级。
- 确定最后的叶子节点:最后一个叶子节点的连接线是没有往下的,这里需要做判断。
- 插槽透传:因为需要递归,一些插槽的透传也是必不可少的。
- 重新渲染:有时数据结构变了,但是组件的并没有重新渲染导致与预期不一致。
实现
确定层级
在组件
props
中 保留一个默认层级level = 0
,当有子节点时把:level = "level + 1"
传入子组件中即可完成标识。例如第一层的level = 0
,第二层的level = 1
。后续还可以根据level
计算缩进线宽度。
连接线
1.第一个节点没有上边线,这个很好判断。
2.中间的节点有上边线,下边线,缩进线。
3.最后一个节点没有下边线,这个不太好判断。
4.点还是圆圈,这个很好判断。
5.缩进线的宽度计算公式:level * 每一节缩进线的宽度
,这里默认一节缩进线宽度为16
;
例如:第一层的双亲节点他的缩进线宽度为:0 * 16 = 0
例如:第二层的叶子节点他的缩进线宽度为:1 * 16 =16
重新渲染
在组件上加
key
,改变key
即可强制重新渲染。
确定最后的叶子节点
这个在组件内不好判断呐,那我干脆让使用者来判断。让他来做这件事,那在编程中怎么去做事呢?当然是
函数
辣,让用户传入函数
,根据函数
调用返回的结果去决定是否是最后的叶子节点。
我把数据都给你,我让你自己去判断。
插槽透传
循环
$slots
即可
上面这样写ts会报错,但是其实是没问题,其它项目也是这么写的都没问题。但是为了不报红我还是用下面的写法
源码
<template>
<div v-bind="$attrs" class="tree-wrap">
<template v-for="(chapter, chapterIdx) in treeData" :key="chapter.id">
<div class="tree-node">
<div class="node-desc" :style="descStyle">
<slot
v-if="$slots.desc"
name="desc"
:chapter="chapter"
:chapterIdx="chapterIdx"
:level="level"
></slot>
<span v-else>{{ nodeDescText(level, chapterIdx) }}</span>
</div>
<div class="node-dot-line" :style="nodeDotLineStyle">
<div v-if="shouldShowTopVertical(chapterIdx)" class="line-top"></div>
<div
:class="[shouldShowHorizontal(level, chapter) ? 'circle' : 'dot']"
:style="nodeDotCircleStyle"
></div>
<div v-if="isRenderLineBottom(level, chapter, chapterIdx)" class="line-bottom"></div>
<div
v-if="shouldShowHorizontal(level, chapter)"
class="line-horizontal"
:style="horizontalLineStyle"
></div>
</div>
<div class="node-info" :style="nodeInfoStyle">
<slot
v-if="$slots.title"
name="title"
:chapter="chapter"
:chapterIdx="chapterIdx"
:level="level"
></slot>
<span v-else :class="{ 'font-bold': level === 0 }">{{ chapter.chapterTitle }}</span>
</div>
<div v-if="$slots.extra" class="extra" :style="extraStyle">
<slot name="extra" :chapter="chapter" :chapterIdx="chapterIdx" :level="level"></slot>
</div>
</div>
<PptOutline
v-if="chapter && chapter.chapterContents"
:level="level + 1"
:tree-data="chapter.chapterContents"
:key="chapter.id"
:is-render-line-bottom="isRenderLineBottom"
>
<template v-for="(_slotFn, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
<slot v-if="slotName === 'desc'" name="desc" v-bind="slotProps"></slot>
<slot v-if="slotName === 'title'" name="title" v-bind="slotProps"></slot>
<slot v-if="slotName === 'extra'" name="extra" v-bind="slotProps"></slot>
</template>
</PptOutline>
</template>
</div>
</template>
<script setup lang="ts">
import PptOutline from '@/components/PptOutline.vue'
const props = withDefaults(
defineProps<{
level?: number
treeData: ChaptersItem[]
isRenderLineBottom?: (level: number, chapter: ChaptersItem, chapterIdx: number) => boolean
}>(),
{
level: 0,
isRenderLineBottom: () => true
}
)
const slots = useSlots()
const dotSize = 7
const descWidth = 50
const extraWidth = 100
const subNodeLineLeft = 16
const descStyle = {
width: `${descWidth}px`
}
const extraStyle = {
width: `${extraWidth}px`
}
// 横向连接线长度
const horizontalLineWidth = computed(() => props.level * subNodeLineLeft)
const horizontalLineStyle = computed(() => ({
width: `${horizontalLineWidth.value}px`
}))
// 节点点样式
const nodeDotCircleStyle = computed(() => {
if (props.level === 0) {
return {
left: `${horizontalLineWidth.value}px`
}
} else {
return {
left: `${horizontalLineWidth.value + dotSize / 2}px`
}
}
})
// 节点点线样式
const nodeDotLineWidth = computed(() => 3 * dotSize + horizontalLineWidth.value)
const nodeDotLineStyle = computed(() => ({
width: `${nodeDotLineWidth.value}px`
}))
// 节点信息样式
const nodeInfoStyle = computed(() => {
if (slots.extra) {
return {
width: `calc(100% - ${descWidth + extraWidth + nodeDotLineWidth.value}px)`
}
} else {
return {
width: `calc(100% - ${descWidth + nodeDotLineWidth.value}px)`
}
}
})
// 是否展示横向连接线
const shouldShowHorizontal = (level: number, chapter: ChaptersItem): boolean =>
level !== 0 && Boolean(chapter)
// 是否展示上半部分纵向连接线
const shouldShowTopVertical = (chapterIdx: number): boolean => chapterIdx !== 0 || props.level > 0
// 展示节点描述信息
const nodeDescText = (level: number, chapterIdx: number): string =>
level === 0 ? `章节${chapterIdx + 1}` : ''
</script>
<style lang="less" scoped>
.tree-wrap {
width: 100%;
@titleColor: #161724;
@titleFontSize: 14px;
@descColor: #8e90a5;
@descFontSize: 12px;
@dotLineColor: #bfc7d6;
@hoverBgColor: #f6f6f6;
@hoverBorderRadius: 6px;
@dotSize: 7px;
@dotLineWidth: 1px;
@subNodeLineleft: 16px;
@dotTop: 15px;
@nodeMinHeight: 36px;
/**每一个节点-start */
.tree-node {
display: flex;
padding: 0 4px 0 20px;
min-height: @nodeMinHeight;
line-height: @nodeMinHeight;
&:hover {
background-color: @hoverBgColor;
border-radius: @hoverBorderRadius;
}
/**节点描述:例如章节1 */
.node-desc {
padding-right: 10px;
color: @descColor;
font-size: @descFontSize;
word-wrap: break-word;
white-space: pre-line;
}
/**节点描述-end */
/**节点连接线-start */
.node-dot-line {
position: relative;
width: 15px;
height: inherit;
.line-top {
position: absolute;
top: 0;
left: calc(@dotSize / 2);
width: @dotLineWidth;
height: calc(@dotTop + @dotSize / 2);
background-color: @dotLineColor;
}
.dot {
position: absolute;
top: @dotTop;
width: @dotSize;
height: @dotSize;
border-radius: 50%;
background-color: @dotLineColor;
}
.circle {
position: relative;
top: @dotTop;
width: @dotSize;
height: @dotSize;
border-radius: 50%;
border: @dotLineWidth solid @dotLineColor;
}
.line-bottom {
position: absolute;
bottom: 0;
left: calc(@dotSize / 2);
width: @dotLineWidth;
height: calc(100% - @dotTop - @dotSize / 2);
background-color: @dotLineColor;
}
.line-horizontal {
position: relative;
top: calc(@dotTop - @dotSize / 2);
left: calc(@dotSize / 2);
width: @subNodeLineleft;
height: @dotLineWidth;
background-color: @dotLineColor;
}
}
/**节点连接线-end */
/**节点信息:例如标题 */
.node-info {
min-width: 400px;
height: fit-content;
font-size: @titleFontSize;
word-wrap: break-word;
white-space: pre-line;
}
/**节点信息-end */
/**节点额外信息:例如图标 */
.extra {
height: @nodeMinHeight;
color: @descColor;
text-align: right;
}
/**节点额外信息-end */
}
/**每一个节点-end */
}
.font-bold {
font-weight: bold;
}
</style>
// 二级标题(章节)
declare interface ChaptersItem {
id: number
chapterTitle: string | null
fileUrl: string | null
fileType: number
chartFlag: string | null
searchFlag: string | null
chapterContents: ChaptersItem[] | null
[key: string]: any
}
// 大纲
declare interface OutLine {
id: number
title: string | null
subTitle: string | null
fileUrl: string | null
fileType: number
chapters: ChaptersItem[] | null
end: string | null
fileId: string | null
[key: string]: any
}
// 大纲返回内容
declare interface PptOutlineData {
sid: string
coverImgSrc: string
title: string
subTitle: string
outline: OutLine
[key: string]: any
}