vite+ts 实现家谱树
需求背景
需要实现可编辑的家谱树,作为入口,增加删除家庭人员,预览和查看家庭人员信息
解决效果
解决方案
index.vue
// index.vue
<template>
<div>
<div class="tree-con">
<h2>递归家谱树</h2>
<TreeChart :json="treedata" @click-node="clickNode"/>
</div>
</div>
</template>
<script lang="ts" setup>
import TreeChart from "./TreeChart.vue";
import data from './data';
import {reactive, toRefs} from "vue";
const state = reactive({
treedata: {} as any//家谱树数据
})
const {treedata} = toRefs(state)
const clickNode = (node: any) => { // 节点点击事件
console.log(node, "==> 节点点击事件")
}
// 异步数据
window.setTimeout(()=>{
state.treedata = data;
},1000)
</script>
data.ts
// data.ts
export interface Data{
id: number;
name: string; // 姓名
headPortrait: string; // 头像url
callName: string; // 称呼
[propName:string]:any; ...
}
export interface TreeData extends Data {
children?: Data[]
mate?: Data[]
}
const genealogTreeData:Data =
{
"id": 1,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "父亲",
"mate": [
{
"id": 2,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "母亲",
"mate": []
}
],
"children": [
{
"id": 3,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "本人",
"mate": []
},
{
"id": 4,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "兄弟",
"children": [
{
"id": 5,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "儿子",
"children": [],
"mate": []
},
{
"id": 6,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "儿子",
"children": [],
"mate": []
}
],
"mate": []
},
{
"id": 7,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "妹妹",
"children": [],
"mate": [
{
"id":8,
"name": "*某某*",
"headPortrait": "https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTLxWhtkFhVKpfXib0BibMaIzeOAVCGVScnR5ibsibdENiaibjvnfy7AxeSSCTbn9IBvqMe1iaJ6BWTxIjZtg/132",
"callName": "妹夫",
"children": [],
"mate": []
}
]
}
],
}
export default genealogTreeData
TreeChart.vue
<!--/**
* @author: liuk
* @date: 2022/12/28
* @describe: 递归族谱树
*/-->
<template>
<table v-if="treeData.id">
<tr>
<td :colspan="Array.isArray(treeData.children) ? treeData.children.length * 2 : 1"
:class="{parentLevel: Array.isArray(treeData.children) && treeData.children.length, extend: Array.isArray(treeData.children) && treeData.children.length && treeData.extend}"
style="word-wrap:break-word;"
>
<div :class="{node: true, hasMate: treeData.mate?.length}">
<popover>
<div class="person"
:class="Array.isArray(treeData.class) ? treeData.class : []"
@click="$emit('click-node', treeData)"
>
<div class="avat">
<img :src="treeData.headPortrait" alt="头像"/>
</div>
<div class="name">{{ treeData.name }}</div>
<div class="relation">{{ treeData.callName || '昵称' }}</div>
</div>
</Popover>
<template v-if="Array.isArray(treeData.mate) && treeData.mate.length">
<popover>
<div class="person" v-for="(mate, mateIndex) in treeData.mate" :key="treeData.name+mateIndex"
:class="Array.isArray(mate.class) ? mate.class : []"
@click="$emit('click-node', mate)"
>
<div class="avat">
<img :src="mate.headPortrait" alt="头像"/>
</div>
<div class="name">{{ mate.name }}</div>
<div class="relation">{{ mate.callName || '昵称' }}</div>
</div>
</popover>
</template>
</div>
<div class="extend_handle" v-if="Array.isArray(treeData.children) && treeData.children.length"
@click="toggleExtend(treeData)"></div>
</td>
</tr>
<tr v-if="Array.isArray(treeData.children) && treeData.children.length && treeData.extend">
<td v-for="(children, index) in treeData.children" :key="index" colspan="2" class="childLevel">
<TreeChart :json="children" @click-node="$emit('click-node', $event)"/>
</td>
</tr>
</table>
</template>
<script lang="ts" setup>
import {ref, watch} from "vue";
import Popover from "./Popover.vue"
import {TreeData,Data} from "./data.ts"; // 这里ts数据
// Prop
const props = defineProps<{
json: TreeData // 族谱书数据
}>();
// Emit
const emit = defineEmits<{
(e: 'click-node', value: any): void
}>()
const treeData= ref({});
const toggleExtend = (node: any) => { // 折叠功能
node.extend = !node.extend;
}
watch(
() => props.json,
(json) => {
let extendKey = function (jsonData: TreeData) {
jsonData.extend = (jsonData.extend === void 0 ? true : !!jsonData.extend); // viod 等价于 undefined
if (Array.isArray(jsonData.children)) {
jsonData.children.forEach((c: Data) => {
extendKey(c)
})
}
return jsonData;
}
if (json) {
treeData.value = extendKey(json);
}
},
{immediate: true}
)
</script>
<style lang="scss" scoped>
table {
margin: auto;
border-collapse: separate !important;
border-spacing: 0 !important;
user-select: none;
td {
position: relative;
vertical-align: top;
padding: 0 0 50px 0;
text-align: center;
&.extend {
&::after {
content: "";
position: absolute;
left: calc(50% - 1px);
bottom: 15px;
height: 15px;
border-left: 2px solid #ccc;
}
.extend_handle:before {
transform: rotate(-45deg);
}
}
.node {
position: relative;
display: inline-block;
margin: 0 1em;
box-sizing: border-box;
text-align: center;
&.hasMate {
width: 200px;
&::after {
content: "";
position: absolute;
left: 2em;
right: 2em;
top: 2em;
border-top: 2px solid #ccc;
z-index: 1;
}
}
.person {
position: relative;
display: inline-block;
z-index: 2;
width: 6em;
overflow: hidden;
.avat {
display: block;
width: 4em;
height: 4em;
margin: auto;
overflow: hidden;
background: #fff;
border: 1px solid #ccc;
box-sizing: border-box;
img {
width: 100%;
height: 100%;
}
}
.name {
height: 2em;
line-height: 2em;
overflow: hidden;
width: 100%;
}
}
}
.extend_handle {
position: absolute;
left: calc(50% - 15px);
bottom: 30px;
width: 10px;
height: 10px;
padding: 10px;
cursor: pointer;
&::before {
content: "";
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
border: 4px solid;
border-color: #ccc #ccc transparent transparent;
transform: rotate(135deg);
transform-origin: 50% 50% 0;
transition: transform ease 300ms;
}
&:hover::before {
border-color: #333 #333 transparent transparent;
}
}
}
.childLevel {
&::before {
content: "";
position: absolute;
left: calc(50% - 1px);
bottom: 100%;
height: 15px;
border-left: 2px solid #ccc;
}
&::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: -15px;
border-top: 2px solid #ccc;
}
&:first-child {
&::before {
display: none
}
&::after {
left: calc(50% - 1px);
height: 15px;
border: 2px solid;
border-color: #ccc transparent transparent #ccc;
border-radius: 6px 0 0 0;
}
}
&:last-child {
&::before {
display: none
}
&::after {
right: calc(50% - 1px);
height: 15px;
border: 2px solid;
border-color: #ccc #ccc transparent transparent;
border-radius: 0 6px 0 0;
}
}
}
}
/*菜单栏*/
:deep(.el-popover.el-popper) {
min-width: 100px !important;
padding: 0 !important;
.el-menu--collapse {
width: 100px;
}
}
</style>
Popover.vue
<!--/**
* @author: liuk
* @date: 2022/12/28
* @describe: 菜单选项弹出框
*/-->
<template>
<el-popover placement="right" :width="100" trigger="click" :teleported="false" :show-arrow="false">
<template #reference>
<slot></slot>
</template>
<el-menu
class="el-menu-demo"
mode="vertical"
:collapse="true"
@select="handleSelect"
style="width:100px;min-width: 100px"
>
<el-menu-item index="preview">查看详情</el-menu-item>
<el-menu-item index="edit">编辑信息</el-menu-item>
<el-popover placement="right" :offset="3" :width="100" trigger="hover" :teleported="false":show-arrow="false">
<template #reference>
<el-menu-item>添加成员 ></el-menu-item>
</template>
<el-menu-item v-for="item of popoverList" :key="item.value" :index="String(item.value)">
{{item.name}}
</el-menu-item>
</el-popover>
<el-menu-item index="del" >删除</el-menu-item>
</el-menu>
</el-popover>
</template>
<script lang="ts" setup>
const popoverList = [
{value: 1, name: '配偶'},
{value: 2, name: '父亲'},
{value: 3, name: '母亲'},
{value: 4, name: '姐姐'},
{value: 5, name: '妹妹'},
{value: 6, name: '兄长'},
{value: 7, name: '弟弟'},
{value: 8, name: '儿子'},
{value: 9, name: '女儿'},
]
const handleSelect = (val: string) => {
console.log(val, "=> 菜单点击事件",typeof val)
}
</script>
##相关逻辑问题
1.中间节点只有一个,不能删除
2.只能有一个配偶,不能添加(前端)
3.添加父亲/母亲时,如果已存在父亲/母亲则不能添加
4.添加兄弟姐妹,如果没有父节点,则不能添加
5.添加人时,按生日升序
视频效果
20230329-1809