This commit is contained in:
owenchen committed 2026-10-15 23:20:36 +08:00
1 parent dcc112c076
commit b858386e86
15 files changed
+3634 -69

No files matched your search

@@ -0,0 +1,578 @@
<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>