This commit is contained in:
owenchen committed 2026-09-02 08:28:45 +08:00
1 parent b26bcfcd3b
commit 8285fb4090
9 files changed
+1372 -20

No files matched your search

@@ -0,0 +1,406 @@
<template>
<dragModal v-model:open="visibleModel" width="85%" :title="title" :mask-closable="false" :fullscreen="fullscreen"
@cancel="onCancel">
<div ref="containerRef" v-if="hasPerson" class="family-chart-container"></div>
<a-empty v-else description="暂无当前族员数据" />
</dragModal>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { Graph } from "@antv/g6";
import dragModal from "@/components/basic/DragModal.vue";
import type { FamilyTreeData } from "@/data/auto/familyTreeData";
import { useLocalePolicy } from "@/hooks/useLocalePolicy";
const props = defineProps({
open: { type: Boolean, default: false },
data: { type: Object as () => FamilyTreeData, required: true },
title: { type: String, default: "谱系图" },
fullscreen: { type: Boolean, default: true },
loadedNodes: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["update:open", "cancel", "select-child"]);
const visibleModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
/** 已展开的节点 personId 集合 */
const expandedIds = ref<Set<string>>(new Set());
const { getPolicyOptions } = useLocalePolicy();
const genderOptions = getPolicyOptions("GenderPolicy", false);
const relationTypeOptions = getPolicyOptions("RelationTypePolicy", false);
interface PolicyOption {
BindValue: string;
DisplayName: string;
}
const getPolicyText = (options: PolicyOption[], value?: string | null) => {
const o = options.find((x) => x.BindValue === value);
return o ? o.DisplayName : value ?? "";
};
const genderLabel = (g?: string) => getPolicyText(genderOptions, g);
const relationLabel = (r?: string) => getPolicyText(relationTypeOptions, r);
const isMale = (g?: string) => {
const t = (genderLabel(g) ?? "").toLowerCase();
return t.includes("男") || t.includes("male") || g === "M" || g === "1";
};
const hasPerson = computed(() => !!props.data?.CurrentPerson?.FamilyPerson);
const rootPersonId = computed(() => props.data?.CurrentPerson?.FamilyPerson?.PersonId ?? "");
/** HTML 字符串转义,防止人名中的特殊字符破坏卡片结构 */
const esc = (v?: string | null) =>
(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** 自定义人员卡片 HTML */
const renderCard = (d: any) => {
const data = d?.data ?? {};
const male = isMale(data.gender);
// 本人=橙色,男性=蓝色,女性=粉色
const theme = data.kind === "self" ? "#fa8c16" : male ? "#1890ff" : "#eb2f96";
const ico = male ? "♂" : "♀";
const common = data.commonName
? `<div class="ftc-sub">常用名:${esc(data.commonName)}</div>`
: "";
const actionTag = data.hasChildren
? `<div class="ftc-tag" style="color:#13c2c2; background:#13c2c21a;">${data.expanded ? "点击折叠" : "点击展开"}</div>`
: !data.loaded
? `<div class="ftc-tag" style="color:#722ed1; background:#722ed11a;">点击查看</div>`
: "";
const clickable = data.hasChildren || !data.loaded ? "ftc-clickable" : "";
return `
<div class="ftc-card ${clickable}" style="border-top: 3px solid ${theme};">
<div class="ftc-head">
<span class="ftc-ico" style="color:${theme};">${ico}</span>
<span class="ftc-name" title="${esc(data.name)}">${esc(data.name)}</span>
</div>
${common}
<div class="ftc-footer">
<div class="ftc-tag" style="color:${theme}; background:${theme}1a;">${esc(data.tag)}</div>
${actionTag}
</div>
</div>`;
};
/** 每个族员的描述:人员信息 + 配偶 + 孩子(根来自 data,其他来自 loadedNodes) */
const describePerson = (personId: string): any => {
if (personId === rootPersonId.value) {
const dp = props.data?.CurrentPerson;
if (!dp?.FamilyPerson) return null;
return {
person: dp.FamilyPerson,
spouses: dp.FamilyTreeSpouseMaps ?? [],
children: dp.FamilyTreeChildrenMaps ?? [],
loaded: true,
};
}
const loaded = (props.loadedNodes as Record<string, any>)?.[personId];
if (loaded) {
return {
person: loaded.person,
spouses: loaded.spouses ?? [],
children: loaded.children ?? [],
loaded: true,
};
}
return null;
};
/** 构建图数据:树形竖向排列,本人默认展开孩子,点击孩子可递归加载子树 */
const buildGraphData = () => {
const nodes: any[] = [];
const edges: any[] = [];
const root = rootPersonId.value;
if (!root) return { nodes, edges };
const Y_STEP = 116;
let cursor = 0;
const walk = (personId: string, depth: number, xBase: number) => {
const desc = describePerson(personId);
if (!desc) return;
const isRoot = personId === root;
const expanded = expandedIds.value.has(personId);
const hasChildren = desc.children.length > 0;
const p = desc.person;
const x = xBase;
const y = cursor;
cursor += Y_STEP;
nodes.push({
id: "n-" + personId,
style: { x, y },
data: {
kind: isRoot ? "self" : "member",
personId,
tag: isRoot ? "本人" : "族员",
name: (p?.Surname ?? "") + (p?.GenealogyName ?? ""),
commonName: p?.CommonName,
gender: p?.Gender,
hasChildren,
expanded,
loaded: desc.loaded,
},
});
// 配偶与本人同一行,水平间隔 200 对齐
const SPOUSE_GAP = 200;
desc.spouses.forEach((s: any, i: number) => {
const sid = "s-" + personId + "-" + i;
nodes.push({
id: sid,
style: { x: x + 210 + i * SPOUSE_GAP, y },
data: {
kind: "spouse",
tag: "配偶",
name: (s.SpouseSurname ?? "") + (s.SpouseGenealogyName ?? ""),
gender: s.Gender,
loaded: true,
hasChildren: false,
expanded: false,
},
});
edges.push({ source: "n-" + personId, target: sid, data: { kind: "spouse" } });
});
if (expanded) {
// 孩子列与本人第一行的配偶列对齐(x + 210),即孩子位于配偶正下方,竖直跟第一行对齐
const childBaseX = x + 210;
desc.children.forEach((c: any) => {
const childId = c.ChildrenId;
if (describePerson(childId)) {
walk(childId, depth + 1, childBaseX);
} else {
nodes.push({
id: "n-" + childId,
style: { x: childBaseX, y: cursor },
data: {
kind: "child",
personId: childId,
tag: c.RelationType ? relationLabel(c.RelationType) : "子女",
name: (c.ChildrenSurname ?? "") + (c.ChildrenGenealogyName ?? ""),
commonName: c.CommonName,
gender: c.Gender,
hasChildren: false,
expanded: false,
loaded: false,
},
});
cursor += Y_STEP;
}
edges.push({ source: "n-" + personId, target: "n-" + childId, data: { kind: "parent" } });
});
}
};
walk(root, 0, 0);
return { nodes, edges };
};
const containerRef = ref<HTMLDivElement>();
let graph: any = null;
const destroyGraph = () => {
if (graph) {
graph.destroy();
graph = null;
}
};
const initGraph = async () => {
destroyGraph();
await nextTick();
// 等待弹窗动画完成,确保容器有实际尺寸
await new Promise((r) => requestAnimationFrame(r));
if (!containerRef.value) return;
const { nodes, edges } = buildGraphData();
if (!nodes.length) return;
graph = new Graph({
container: containerRef.value,
autoFit: "view",
data: { nodes, edges },
node: {
type: "html",
style: {
size: [180, 96],
dx: -90,
dy: -48,
// 官方文档推荐函数形式,类型声明仅支持 string/HTMLElement,故断言
innerHTML: renderCard as any,
},
},
edge: {
type: (d: any) => (d?.data?.kind === "spouse" ? "line" : "polyline"),
style: (d: any) => ({
// 夫妻关系=紫色虚线,亲子关系=灰色实线
stroke: d?.data?.kind === "spouse" ? "#722ed1" : "#a0a0a0",
lineWidth: 1.5,
lineDash: d?.data?.kind === "spouse" ? [4, 4] : undefined,
router: d?.data?.kind === "spouse" ? undefined : { type: "orth" },
}),
},
behaviors: ["drag-canvas", "zoom-canvas", "drag-element"],
});
graph.on("node:click", (event: any) => {
const id = event.target?.id ?? event.item?.getID?.();
if (typeof id !== "string" || !id.startsWith("n-")) return;
const personId = id.slice(2);
const isRoot = personId === rootPersonId.value;
if (isRoot) {
const children = props.data?.CurrentPerson?.FamilyTreeChildrenMaps ?? [];
if (children.length > 0) {
toggleExpanded(personId);
}
return;
}
if (describePerson(personId)) {
toggleExpanded(personId);
} else {
emit("select-child", personId);
}
});
await graph.render();
};
const toggleExpanded = (personId: string) => {
const set = new Set(expandedIds.value);
if (set.has(personId)) set.delete(personId);
else set.add(personId);
expandedIds.value = set;
initGraph();
};
watch(visibleModel, async (v) => {
if (v) {
// 第一个人(当前族员)默认展开孩子
expandedIds.value = new Set([rootPersonId.value].filter(Boolean));
await initGraph();
} else {
destroyGraph();
}
});
watch(
() => props.data,
async () => {
if (visibleModel.value) {
expandedIds.value = new Set([rootPersonId.value].filter(Boolean));
await initGraph();
}
},
{ deep: true }
);
watch(
() => props.loadedNodes,
async (val) => {
if (!val || !visibleModel.value) return;
// 新加载的族员自动展开,方便查看其子树
const set = new Set(expandedIds.value);
Object.keys(val as Record<string, any>).forEach((k) => set.add(k));
expandedIds.value = set;
await initGraph();
},
{ deep: true }
);
onBeforeUnmount(() => destroyGraph());
const onCancel = () => emit("cancel");
</script>
<style>
.family-chart-container {
width: 100%;
height: 72vh;
min-height: 480px;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
/* G6 HTML 节点卡片样式(注入到 canvas 容器,使用独立前缀避免污染全局) */
.ftc-card {
box-sizing: border-box;
width: 100%;
height: 100%;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 8px 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
user-select: none;
}
.ftc-head {
display: flex;
align-items: center;
gap: 6px;
}
.ftc-ico {
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.ftc-name {
font-size: 14px;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ftc-sub {
font-size: 12px;
color: #888;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ftc-footer {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.ftc-tag {
font-size: 11px;
line-height: 1;
border-radius: 3px;
padding: 3px 6px;
}
.ftc-clickable {
cursor: pointer;
}
.ftc-clickable:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>