Files
Docker/uniapp/youjiala/src/components/family-tree-chart/family-tree-chart.vue
T
2026-10-15 23:20:36 +08:00

579 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 族谱图(原生递归树容器):全平台不依赖 @antv/g6,用 scroll-view + 递归 family-tree-node 绘制。
*
* 一个组件承载两种数据源(对齐旧 WebVue 的两张 G6 图):
* - mode="sub"(谱系图):根 = 当前族员(props.root.CurrentPerson),子树按需懒加载——
* 点未加载的子女 emit("select-child"),页面拉完写进 props.loadedNodes 再回来展开;
* - mode="full"(祖谱系图):组件内部 FindAllByTreeId 拉全族三张表(人员/夫妻/亲子)拍平索引,
* 从始祖向下渲染;同一孩子挂多个父时只在「承嗣/主承担」父下展开,其余父仅显示引用卡(对齐旧图)。
*
* 展开态、性别/关系文案、头像查表都集中在这里,node 只经 inject 的 api 取用(见 types.ts)。
*/
import type { LoadedTreeNode } from "./types";
import type { FamilyTreeChildrenMapViewEntity } from "@/data/auto/familyTreeChildrenMapViewEntity";
import type { FamilyTreeData } from "@/data/auto/familyTreeData";
import type { FamilyTreeSpouseMapViewEntity } from "@/data/auto/familyTreeSpouseMapViewEntity";
import { useLocale } from "uview-pro";
import { computed, provide, ref, watch } from "vue";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import familyTreeService from "@/service/master/familyTreeService";
import { FAMILY_TREE_CHART_API } from "./types";
const props = defineProps({
visible: { type: Boolean, default: false },
/** sub=当前族员子树(懒加载);full=祖谱全树 */
mode: { type: String as () => "sub" | "full", default: "sub" },
treeId: { type: String, default: "" },
/** sub 模式数据源:当前族员所在族谱 */
root: { type: Object as () => FamilyTreeData | null, default: null },
/** sub 模式已展开加载的族员(personId -> 子树) */
loadedNodes: { type: Object as () => Record<string, LoadedTreeNode>, default: () => ({}) },
title: { type: String, default: "" },
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "select-child", personId: string): void;
(e: "pick-person", personId: string): void;
}>();
const { t } = useLocale();
const { getPolicyText } = usePolicyOptions();
/** 展开状态:personId -> 是否展开(子树/全树共用) */
const expanded = ref<Record<string, boolean>>({});
const loading = ref(false);
// ===== 全树模式:FindAllByTreeId 拍平出的索引 =====
interface FullIndex {
rootId: string;
personMap: Map<string, any>;
spousesOf: Map<string, any[]>;
childrenOf: Map<string, any[]>;
parentsOf: Map<string, { parentId: string; relation?: string }[]>;
/** 当前族人 PersonId(FindAllByTreeId 的 CurrentPerson) */
currentId: string;
/** 主系链:父 PersonId -> 该父子女中位于「祖→当前族人」链上的那一个,排序时置顶(旧图主系居首同款) */
lineageChildOf: Map<string, string>;
}
const fullIndex = ref<FullIndex | null>(null);
/** 承祧 / 入嗣 / 收养类关系:孩子过继到该父名下,由其承担后代世系(对齐旧图) */
const ADOPTIVE_RELATIONS = ["C", "E", "F", "G", "H", "I", "J", "K", "L"];
function isMale(gender?: string): boolean {
if (!gender) {
return true;
}
const label = getPolicyText("GenderPolicy", gender).toLowerCase();
return label.includes("男") || label.includes("male") || gender === "M" || gender === "1";
}
function childTag(relationType?: string): string {
return relationType ? getPolicyText("RelationTypePolicy", relationType) || "子女" : "子女";
}
function byOrder(a: { Orderby?: number }, b: { Orderby?: number }): number {
return (a.Orderby ?? 0) - (b.Orderby ?? 0);
}
/** 全族头像查表:sub 用 root.CurrentPerson.FamilyTreePersons + loadedNodes;full 用 personMap */
function getAvatar(personId: string): string {
const subPersons = (props.root?.CurrentPerson?.FamilyTreePersons ?? []) as any[];
const hit = subPersons.find(p => p?.PersonId === personId);
if (hit?.AvatarUrl) {
return hit.AvatarUrl;
}
const loaded = props.loadedNodes[personId];
if (loaded?.person?.AvatarUrl) {
return loaded.person.AvatarUrl;
}
const full = fullIndex.value?.personMap.get(personId);
return full?.AvatarUrl ?? "";
}
/** 性别查表:关系视图里的 Gender 是拥有者(丈夫/父亲)本人的字段,配偶/子女必须回查族人档案拿真实性别 */
function getGender(personId: string): string {
const subPersons = (props.root?.CurrentPerson?.FamilyTreePersons ?? []) as any[];
const hit = subPersons.find(p => p?.PersonId === personId);
if (hit?.Gender) {
return hit.Gender;
}
const loaded = props.loadedNodes[personId];
if (loaded?.person?.Gender) {
return loaded.person.Gender;
}
return fullIndex.value?.personMap.get(personId)?.Gender ?? "";
}
const rootPersonId = computed(() => {
if (props.mode === "full") {
return fullIndex.value?.rootId ?? "";
}
return props.root?.CurrentPerson?.FamilyPerson?.PersonId ?? "";
});
const hasRoot = computed(() => Boolean(rootPersonId.value) && describe(rootPersonId.value) !== null);
/** 把一份「人 + 配偶 + 子女」原始记录归一化成 ResolvedNode(sub:来自 CurrentPerson/loadedNodes) */
function resolveSubNode(personId: string, record: { person: any; spouses: FamilyTreeSpouseMapViewEntity[]; children: FamilyTreeChildrenMapViewEntity[] }, isRoot: boolean) {
const p = record.person;
const self = {
personId,
name: `${p.Surname ?? ""}${p.GenealogyName ?? ""}`,
commonName: p.CommonName ?? "",
gender: p.Gender ?? "",
avatarUrl: p.AvatarUrl ?? getAvatar(personId),
tag: isRoot ? "本人" : "族员",
};
const spouses = [...record.spouses].sort(byOrder).map((s: any) => ({
personId: s.SpouseId,
name: `${s.SpouseSurname ?? ""}${s.SpouseGenealogyName ?? ""}`,
commonName: s.SpouseCommonName ?? "",
gender: getGender(s.SpouseId),
avatarUrl: getAvatar(s.SpouseId),
tag: "配偶",
}));
const children = [...record.children].sort(byOrder).map((c: any) => {
const childId = c.ChildrenId;
const loaded = childId === rootPersonId.value || Boolean(props.loadedNodes[childId]);
return {
personId: childId,
card: {
personId: childId,
name: `${c.ChildrenSurname ?? ""}${c.ChildrenGenealogyName ?? ""}`,
commonName: c.ChildrenCommonName ?? "",
gender: getGender(childId),
avatarUrl: getAvatar(childId),
tag: childTag(c.RelationType),
},
loaded,
expandable: loaded,
};
});
return { self, spouses, children, loaded: true };
}
/** 全树节点归一化:索引数据恒已加载,expandable 由「主承担父」判定 */
function resolveFullNode(personId: string): any | null {
const index = fullIndex.value;
if (!index) {
return null;
}
const info = index.personMap.get(personId);
if (!info) {
return null;
}
const isRoot = personId === index.rootId;
const self = {
personId,
name: `${info.Surname ?? ""}${info.GenealogyName ?? ""}`,
commonName: info.CommonName ?? "",
gender: info.Gender ?? "",
avatarUrl: getAvatar(personId),
tag: isRoot ? "始祖" : "族员",
};
const spouses = [...(index.spousesOf.get(personId) ?? [])].sort(byOrder).map((s: any) => ({
personId: s.id,
name: s.name,
commonName: s.commonName ?? "",
gender: s.gender || index.personMap.get(s.id)?.Gender || "",
avatarUrl: getAvatar(s.id),
tag: "配偶",
}));
// 主系置顶:链向当前族人的那个子女排第 1,其余兄弟依原始顺序跟在后
const spineId = index.lineageChildOf.get(personId);
const children = [...(index.childrenOf.get(personId) ?? [])].sort((a: any, b: any) => {
if (spineId && a.id === spineId && b.id !== spineId) {
return -1;
}
if (spineId && b.id === spineId && a.id !== spineId) {
return 1;
}
return byOrder(a, b);
}).map((c: any) => {
const primary = primaryParentOf(c.id);
return {
personId: c.id,
card: {
personId: c.id,
name: c.name,
commonName: c.commonName ?? "",
gender: c.gender || index.personMap.get(c.id)?.Gender || "",
avatarUrl: getAvatar(c.id),
tag: childTag(c.relation),
},
loaded: true,
expandable: primary === personId,
};
});
return { self, spouses, children, loaded: true };
}
function primaryParentOf(personId: string): string {
const parents = fullIndex.value?.parentsOf.get(personId) ?? [];
if (parents.length === 0) {
return "";
}
const adopted = parents.find(p => ADOPTIVE_RELATIONS.includes(p.relation ?? ""));
return (adopted ?? parents[0]).parentId;
}
/** 供 node 调用:按模式解析某人;无法解析返回 null */
function describe(personId: string) {
if (!personId) {
return null;
}
if (props.mode === "full") {
return resolveFullNode(personId);
}
if (personId === rootPersonId.value) {
const cp = props.root?.CurrentPerson;
if (!cp?.FamilyPerson) {
return null;
}
return resolveSubNode(personId, {
person: cp.FamilyPerson,
spouses: cp.FamilyTreeSpouseMaps ?? [],
children: cp.FamilyTreeChildrenMaps ?? [],
}, true);
}
const loaded = props.loadedNodes[personId];
if (loaded) {
return resolveSubNode(personId, loaded, false);
}
return null;
}
function toggle(personId: string): void {
expanded.value = { ...expanded.value, [personId]: !expanded.value[personId] };
}
// ===== 缩放与布局方向(弹窗右上控制条)=====
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.1;
const zoom = ref(1);
// 默认纵向:世代向下推进、主系居首列(⇅ 可切回横向阶梯)
const vertical = ref(true);
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
function clampZoom(value: number): number {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value * 10) / 10));
}
function zoomIn(): void {
zoom.value = clampZoom(zoom.value + ZOOM_STEP);
}
function zoomOut(): void {
zoom.value = clampZoom(zoom.value - ZOOM_STEP);
}
function toggleDirection(): void {
vertical.value = !vertical.value;
}
function requestLoad(personId: string): void {
emit("select-child", personId);
}
function requestPick(personId: string): void {
emit("pick-person", personId);
}
provide(FAMILY_TREE_CHART_API, {
describe,
expanded,
toggle,
requestLoad,
requestPick,
isMale,
vertical,
});
/** 全树:从 FamilyTreeData 拍平三张索引表(照抄旧 buildIndex 的字段映射) */
function buildFullIndex(resp: FamilyTreeData): FullIndex {
const personMap = new Map<string, any>();
const spousesOf = new Map<string, any[]>();
const childrenOf = new Map<string, any[]>();
const parentsOf = new Map<string, { parentId: string; relation?: string }[]>();
const cp = resp?.CurrentPerson;
(cp?.FamilyTreePersons ?? []).forEach((p: any) => {
if (p?.PersonId) {
personMap.set(p.PersonId, { ...p });
}
});
const ancestor = cp?.FamilyPerson;
if (ancestor?.PersonId && !personMap.has(ancestor.PersonId)) {
personMap.set(ancestor.PersonId, { ...ancestor });
}
const pushSpouse = (ownerId: string, entry: any) => {
if (!ownerId) {
return;
}
const list = spousesOf.get(ownerId) ?? [];
if (!list.some(x => x.id === entry.id)) {
list.push(entry);
spousesOf.set(ownerId, list);
}
};
(cp?.FamilyTreeSpouseMaps ?? []).forEach((s: any) => {
pushSpouse(s.PersonId, {
id: s.SpouseId,
name: `${s.SpouseSurname ?? ""}${s.SpouseGenealogyName ?? ""}`,
commonName: s.SpouseCommonName,
// 不取 s.Gender:那是拥有者本人的性别,配偶真实性别由 personMap 回查(见 resolveFullNode)
gender: undefined,
orderby: s.Orderby,
});
pushSpouse(s.SpouseId, {
id: s.PersonId,
name: `${s.Surname ?? ""}${s.GenealogyName ?? ""}`,
commonName: s.CommonName,
gender: undefined,
orderby: s.Orderby,
});
});
(cp?.FamilyTreeChildrenMaps ?? []).forEach((c: any) => {
const list = childrenOf.get(c.PersonId) ?? [];
if (!list.some(x => x.id === c.ChildrenId)) {
list.push({
id: c.ChildrenId,
name: `${c.ChildrenSurname ?? ""}${c.ChildrenGenealogyName ?? ""}`,
commonName: c.ChildrenCommonName,
// 同上:c.Gender 是父亲的性别,子女真实性别由 personMap 回查
gender: undefined,
relation: c.RelationType,
orderby: c.Orderby,
});
childrenOf.set(c.PersonId, list);
const parentList = parentsOf.get(c.ChildrenId) ?? [];
parentList.push({ parentId: c.PersonId, relation: c.RelationType });
parentsOf.set(c.ChildrenId, parentList);
}
});
const rootId = ancestor?.PersonId || resp?.FamilyTree?.AncestorPersonId || "";
// 主系链:从当前族人逐级向上到始祖,记下每一环「父 -> 链上子」,渲染时把该子排到子女首位。
// 锚点优先用页面当前选中族人(props.root,如切到表妹);FindAllByTreeId 的 CurrentPerson 是登录者本人,仅作兜底
const currentId = props.root?.CurrentPerson?.FamilyPerson?.PersonId || cp?.FamilyPerson?.PersonId || "";
const lineageChildOf = new Map<string, string>();
const visited = new Set<string>();
let cursor = currentId;
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
const parentId = (parentsOf.get(cursor) ?? [])[0]?.parentId ?? "";
if (!parentId) {
break;
}
lineageChildOf.set(parentId, cursor);
cursor = parentId;
}
return { rootId, personMap, spousesOf, childrenOf, parentsOf, currentId, lineageChildOf };
}
async function loadFull(): Promise<void> {
const treeId = props.treeId.trim();
if (!treeId) {
fullIndex.value = null;
return;
}
try {
loading.value = true;
const resp = await familyTreeService.FindAllByTreeId(treeId);
if (resp?.CurrentPerson) {
fullIndex.value = buildFullIndex(resp);
}
else {
fullIndex.value = null;
}
}
catch (error) {
console.error(error);
fullIndex.value = null;
}
finally {
loading.value = false;
}
}
/** 弹窗打开:重置展开态到根节点;full 默认全树展开(旧祖谱系图同款),sub 仅展开根待懒加载 */
watch(
() => props.visible,
(on) => {
if (!on) {
return;
}
expanded.value = {};
if (props.mode === "full") {
loadFull().then(() => {
const next: Record<string, boolean> = {};
if (rootPersonId.value) {
next[rootPersonId.value] = true;
}
// 所有有子女的族员一律展开;非主承担父由 node 的 expandable 门控,不会重复渲染子树
fullIndex.value?.childrenOf.forEach((_list: any[], parentId: string) => {
next[parentId] = true;
});
expanded.value = next;
});
}
else if (rootPersonId.value) {
expanded.value = { [rootPersonId.value]: true };
}
},
);
/** sub 模式:新加载的族员自动展开,方便直接看其子树(对齐旧图 watch loadedNodes) */
watch(
() => Object.keys(props.loadedNodes),
(ids) => {
if (!props.visible || props.mode !== "sub") {
return;
}
const next = { ...expanded.value };
for (const id of ids) {
next[id] = true;
}
expanded.value = next;
},
);
function close(): void {
emit("update:visible", false);
}
</script>
<template>
<u-popup :model-value="props.visible" mode="bottom" round="0" :safe-area-inset-bottom="true" @close="close">
<view class="ftc">
<view class="ftc__bar">
<text class="ftc__title">
{{ props.title || t('FamilyTreePage.pedigreeChart') }}
</text>
<u-icon name="close" size="40" color="#8f959e" @click="close" />
</view>
<!-- 悬浮控制条:放大/缩小/百分比复位/横纵布局切换(对齐旧图 G6 工具钮) -->
<view class="ftc__ctrl">
<view class="ftc__ctrl-btn" @tap="zoomIn">
<text class="ftc__ctrl-glyph">+</text>
</view>
<view class="ftc__ctrl-btn ftc__ctrl-btn--label" @tap="zoom = 1">
<text class="ftc__ctrl-label">{{ zoomLabel }}</text>
</view>
<view class="ftc__ctrl-btn" @tap="zoomOut">
<text class="ftc__ctrl-glyph">-</text>
</view>
<view class="ftc__ctrl-btn" @tap="toggleDirection">
<text class="ftc__ctrl-glyph">⇅</text>
</view>
</view>
<scroll-view class="ftc__scroll" scroll-x scroll-y :show-scrollbar="true">
<view v-if="hasRoot" class="ftc__tree" :style="{ zoom }">
<family-tree-node :person-id="rootPersonId" :preview="null" :expandable="true" :depth="0" />
</view>
<view v-else class="ftc__empty">
<text>{{ loading ? t('AdminPage.loadingTip') : t('FamilyTreePage.chartEmpty') }}</text>
</view>
</scroll-view>
</view>
</u-popup>
</template>
<style lang="scss" scoped>
.ftc {
position: relative;
display: flex;
flex-direction: column;
/* 默认满屏:bottom 弹窗内容撞满视口,顶部预留安全区 */
height: 100vh;
background-color: #f5f6f8;
}
.ftc__bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: calc(28rpx + env(safe-area-inset-top)) 32rpx 28rpx;
background-color: #ffffff;
border-bottom: 2rpx solid #f2f3f5;
}
.ftc__title {
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
}
/* 悬浮控制条:右侧竖排,白底圆角轻阴影 */
.ftc__ctrl {
position: absolute;
top: calc(120rpx + env(safe-area-inset-top));
right: 24rpx;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
}
.ftc__ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 76rpx;
border-bottom: 2rpx solid #f2f3f5;
}
.ftc__ctrl-btn--label {
border-bottom-color: #f2f3f5;
}
.ftc__ctrl-glyph {
font-size: 34rpx;
color: #1f2329;
}
.ftc__ctrl-label {
font-size: 24rpx;
color: #646a73;
}
.ftc__scroll {
flex: 1;
min-height: 0;
white-space: nowrap;
}
.ftc__tree {
display: inline-block;
box-sizing: border-box;
/* 按内容实际宽度撑开:inline-block 默认被可用宽度封顶,超出部分只溢出不扩滚动区,
scroll-x 必须靠 max-content 拿到真实宽度才能横向滚起来;窄于容器时由 min-width 撑满 */
width: max-content;
min-width: 100%;
padding: 24rpx;
}
.ftc__empty {
display: flex;
align-items: center;
justify-content: center;
height: 320rpx;
font-size: 26rpx;
color: #8f959e;
}
</style>