vite+ts实现 家谱树

该文章展示了一个用Vite和Typescript实现的可编辑家谱树组件。组件支持节点的增删,点击节点事件,以及预览和查看家庭人员信息。数据结构包含姓名、头像、称呼等属性,并通过递归实现树形结构。同时,文章附带了视频效果和代码示例。
摘要由CSDN通过智能技术生成

需求背景

需要实现可编辑的家谱树,作为入口,增加删除家庭人员,预览和查看家庭人员信息

解决效果

效果链接
在这里插入图片描述

解决方案

代码链接

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>添加成员 &gt;</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

在 Vue 3 中结合 ViteTypeScript 实现导出 PDF 功能,你可以使用一些外部库如 jsPDF 或 html2canvas 结合 pdfmake 或者 puppeteer 来完成这个任务。以下是一个概述: 1. **安装必要的库**: - 首先,需要安装 `vue-pdf`、`html2canvas`、`jsPDF` 和 `vue-vjspdf` (如果使用 vue-vjspdf)。运行以下命令: ``` npm install @vueuse/vue-pdf html2canvas jspdf vue-vjspdf --save ``` 2. **引入并配置**: - 在 `main.ts` 或 `setup.ts` 中导入并配置这些库: ```typescript import { createApp } from 'vue'; import VuePdf from '@vueuse/vue-pdf'; // 如果使用 vue-vjspdf import { VueVjspdf } from 'vue-vjspdf'; const app = createApp(App); app.use(VuePdf, { autoDownload: false, // 自动下载选项 // 更多配置项... }); if (process.env.NODE_ENV === 'production') { app.use(VueVjspdf); // 如果你想生成PDF } ``` 3. **创建PDF导出组件**: - 创建一个组件,比如 `ExportToPdf.vue`,在这个组件中使用这些库来渲染HTML到PDF: ```html <template> <button @click="exportToPdf">导出为PDF</button> </template> <script lang="ts"> import { ref } from 'vue'; import { exportDocument } from 'vue-pdf'; export default { methods: { async exportToPdf() { const content = document.getElementById('content-to-export'); // 获取你要转换的HTML元素 const pdfBlob = await exportDocument(content.innerHTML); // 可能需要下载或者处理blob // download blob 或者将 blob 转换为URL提供给用户预览 }, }, }; </script> ``` 4. **使用 HTML2Canvas + jsPDF**: - 如果你更倾向于从纯HTML生成PDF,可以使用 html2canvas 将网页内容转成 canvas,然后用 jsPDF 加入到 PDF 文件中。 5. **部署注意事项**: - 确保服务器环境支持 Blob 对象的下载,以及可能的跨域问题。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柳晓黑胡椒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值