Files
Docker/WebVue/src/components/master/FamilyTreeChartModal.vue
T
2026-09-17 11:49:35 +08:00

598 lines
19 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.
<template>
<dragModal v-model:open="visibleModel" width="85%" :title="title" :mask-closable="false" :fullscreen="fullscreen"
@cancel="onCancel">
<div v-if="hasPerson" class="family-chart-wrap">
<div ref="containerRef" class="family-chart-container"></div>
<div class="ftc-zoom-controls">
<button class="ftc-zoom-btn" title="放大" @click="zoomBy(ZOOM_STEP)">+</button>
<button class="ftc-zoom-btn" title="缩小" @click="zoomBy(-ZOOM_STEP)">-</button>
<button class="ftc-zoom-btn" title="适应画布" @click="fitGraph">⤢</button>
</div>
</div>
<a-empty v-else description="暂无当前族员数据" />
<template #footer>
<a-button type="primary" @click="onCancel">关闭</a-button>
</template>
</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) => {
if (!g) return true;
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 ?? "");
/** personId -> 头像访问地址(本人 / 全族人员 / 已加载族员) */
const avatarMap = computed(() => {
const map: Record<string, string> = {};
const root: any = props.data?.CurrentPerson?.FamilyPerson;
if (root?.PersonId && root?.AvatarUrl) map[root.PersonId] = root.AvatarUrl;
((props.data?.CurrentPerson?.FamilyTreePersons ?? []) as any[]).forEach((p: any) => {
if (p?.PersonId && p?.AvatarUrl) map[p.PersonId] = p.AvatarUrl;
});
Object.values((props.loadedNodes ?? {}) as Record<string, any>).forEach((n: any) => {
const p = n?.person;
if (p?.PersonId && p?.AvatarUrl) map[p.PersonId] = p.AvatarUrl;
});
return map;
});
/** HTML 字符串转义,防止人名中的特殊字符破坏卡片结构 */
const esc = (v?: string | null) =>
(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** 无头像时的默认头像图标(白底 + 性别色人像:男蓝 / 女粉) */
const USER_ICON_SVG =
'<svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M858.5 763.6a374 374 0 00-80.6-119.5 375.63 375.63 0 00-119.5-80.6c-.4-.2-.8-.3-1.2-.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-.4.2-.8.3-1.2.5-44.8 18.9-85 46-119.5 80.6a375.63 375.63 0 00-80.6 119.5A371.7 371.7 0 00136 901.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c.1 4.4 3.6 7.8 8 7.8h60a8 8 0 008-8.2c-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z"></path></svg>';
/** 自定义人员卡片 HTML */
const renderCard = (d: any) => {
const data = d?.data ?? {};
const male = isMale(data.gender);
// 本人=橙色,男性=蓝色,女性=粉色
const theme = data.kind === "self" ? "#fa8c16" : male ? "#1890ff" : "#eb2f96";
// 无头像占位图标颜色:男蓝 / 女粉(白底)
const avatarColor = male ? "#1890ff" : "#eb2f96";
// 有头像显示头像,无头像显示默认头像(白底 + 性别色人像图标)
const avatar = data.avatarUrl
? `<img class="ftc-avatar" src="${esc(data.avatarUrl)}" alt="" onerror="this.style.visibility='hidden'" />`
: "";
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 ftc-card-lg ${clickable}" style="border-top: 3px solid ${theme};">
<div class="ftc-main">
<div class="ftc-avatar-wrap" style="border-color:${theme};">
<span class="ftc-avatar-ph" style="color:${avatarColor};">${USER_ICON_SVG}</span>${avatar}
</div>
<div class="ftc-info">
<div class="ftc-name" title="${esc(data.name)}">${esc(data.name)}</div>
${common}
</div>
</div>
<div class="ftc-footer">
<div class="ftc-tag" style="color:${theme}; background:${theme}1a;">${esc(data.tag)}</div>
${actionTag}
</div>
</div>`;
};
/** 同级(配偶/孩子)按 Orderby 升序排列 */
const byOrder = (a: any, b: any) => (a.Orderby ?? 0) - (b.Orderby ?? 0);
/** 每个族员的描述:人员信息 + 配偶 + 孩子(根来自 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 ?? [])].sort(byOrder),
children: [...(dp.FamilyTreeChildrenMaps ?? [])].sort(byOrder),
loaded: true,
};
}
const loaded = (props.loadedNodes as Record<string, any>)?.[personId];
if (loaded) {
return {
person: loaded.person,
spouses: [...(loaded.spouses ?? [])].sort(byOrder),
children: [...(loaded.children ?? [])].sort(byOrder),
loaded: true,
};
}
return null;
};
/** 构建图数据:树形竖向排列,本人默认展开孩子,点击孩子可递归加载子树 */
const buildGraphData = () => {
const nodes: any[] = [];
const edges: any[] = [];
const root = rootPersonId.value;
if (!root) return { nodes, edges };
const CARD_WIDTH = 360;
const CARD_HEIGHT = 220;
const H_GAP = 60;
const V_GAP = 40;
const X_STEP = CARD_WIDTH + H_GAP; // 420,避免水平重叠
const Y_STEP = CARD_HEIGHT + V_GAP; // 232,避免垂直重叠
const SPOUSE_GAP = X_STEP;
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,
avatarUrl: p?.AvatarUrl ?? avatarMap.value[personId] ?? "",
hasChildren,
expanded,
loaded: desc.loaded,
},
});
// 配偶与本人同一行,水平间隔 X_STEP 对齐
desc.spouses.forEach((s: any, i: number) => {
const sid = "s-" + personId + "-" + i;
nodes.push({
id: sid,
style: { x: xBase + X_STEP + i * SPOUSE_GAP, y },
data: {
kind: "spouse",
personId: s.SpouseId,
tag: "配偶",
name: (s.SpouseSurname ?? "") + (s.SpouseGenealogyName ?? ""),
gender: s.Gender,
avatarUrl: avatarMap.value[s.SpouseId] ?? "",
loaded: true,
hasChildren: false,
expanded: false,
},
});
edges.push({ source: "n-" + personId, target: sid, data: { kind: "spouse" } });
});
if (expanded) {
// 孩子列与本人第一行的配偶列对齐(xBase + X_STEP),即孩子位于配偶正下方,竖直跟第一行对齐
const childBaseX = xBase + X_STEP;
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.ChildrenCommonName,
gender: c.Gender,
avatarUrl: avatarMap.value[childId] ?? "",
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;
/** 卡片最小显示比例:世代过多时 fitView 会把整图缩得很小,卡片将看不清,低于该比例时放大 */
const MIN_ZOOM = 0.4;
/** 缩放按钮单次增量 */
const ZOOM_STEP = 0.1;
/** 单击按钮放大/缩小一点点(按增量叠加并限制在 zoomRange 内) */
const zoomBy = (delta: number) => {
if (!graph) return;
const next = graph.getZoom() + delta;
graph.zoomTo(Math.min(Math.max(next, MIN_ZOOM), 10));
};
/** 还原为适配整个画布 */
const fitGraph = async () => {
if (!graph) return;
await graph.fitView();
if (graph.getZoom() < MIN_ZOOM) {
await graph.zoomTo(MIN_ZOOM);
}
};
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: { type: "view", options: { when: "overflow", direction: "both" } },
zoomRange: [MIN_ZOOM, 10],
data: { nodes, edges },
node: {
type: "html",
style: {
size: [360, 220],
dx: -180,
dy: -110,
// 官方文档推荐函数形式,类型声明仅支持 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"],
});
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();
// 世代过多导致 fitView 缩得太小时,放大到最小显示比例,保证卡片可读
if (graph.getZoom() < MIN_ZOOM) {
await graph.zoomTo(MIN_ZOOM);
}
};
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 = () => {
visibleModel.value = false;
emit("cancel");
};
</script>
<style>
.family-chart-wrap {
position: relative;
height: 100%;
min-height: 480px;
}
.family-chart-container {
width: 100%;
height: 100%;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
/* 画布缩放按钮组(放大/缩小/适应) */
.ftc-zoom-controls {
position: absolute;
top: 12px;
right: 12px;
z-index: 20;
display: flex;
flex-direction: column;
gap: 6px;
}
.ftc-zoom-btn {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 16px;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s, border-color 0.2s;
}
.ftc-zoom-btn:hover {
color: #1890ff;
border-color: #1890ff;
}
/* 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;
}
/* 谱系图大卡片(节点尺寸放大 2 倍,内部文字同步放大) */
.ftc-card-lg {
padding: 12px 16px;
}
.ftc-card-lg .ftc-name {
font-size: 20px;
}
.ftc-card-lg .ftc-sub {
font-size: 15px;
margin-top: 6px;
}
.ftc-card-lg .ftc-tag {
font-size: 13px;
padding: 4px 8px;
}
.ftc-head {
display: flex;
align-items: center;
gap: 6px;
}
/* 人员卡片:左侧头像 + 右侧姓名信息 */
.ftc-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.ftc-info {
flex: 1;
min-width: 0;
}
.ftc-avatar-wrap {
position: relative;
flex: none;
width: 40px;
height: 40px;
border: 2px solid #e8e8e8;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.ftc-card-lg .ftc-avatar-wrap {
width: 104px;
height: 104px;
}
/* 无头像占位:白底 + 性别色人像图标(男蓝 / 女粉,颜色由内联 style 指定) */
.ftc-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #1890ff;
font-size: 20px;
line-height: 1;
}
.ftc-card-lg .ftc-avatar-ph {
font-size: 52px;
}
/* 头像图片绝对定位覆盖占位图标,加载失败时隐藏,露出下方默认头像 */
.ftc-avatar {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.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>