This commit is contained in:
owenchen committed 2026-09-02 10:59:06 +08:00
1 parent 8285fb4090
commit 8a13dfb836
6 files changed
+740 -25

No files matched your search

@@ -13,6 +13,7 @@ using CloudBuilder.Security.Utility;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
namespace CloudBuilder.Master.Service
{
public class FamilyTreeService : IFamilyTreeService
@@ -55,6 +56,24 @@ namespace CloudBuilder.Master.Service
data.CurrentPerson.FamilyTreeChildrenMaps = repositoryChildrenMap.GetRepository<IRepository<FamilyTreeChildrenMapViewEntity>>().DetachedEntities.Where(x => x.TreeId == treeId).OrderBy(x => x.Orderby).ToArray();
List<string> ps = new List<string>();
ps.Add(d.AncestorPersonId);
if (data.CurrentPerson.FamilyTreeSpouseMaps != null && data.CurrentPerson.FamilyTreeSpouseMaps.Length > 0)
{
ps.AddRange(data.CurrentPerson.FamilyTreeSpouseMaps.Select(x => x.SpouseId).ToArray());
}
if (data.CurrentPerson.FamilyTreeChildrenMaps != null && data.CurrentPerson.FamilyTreeChildrenMaps.Length > 0)
{
ps.AddRange(data.CurrentPerson.FamilyTreeChildrenMaps.Select(x => x.ChildrenId).ToArray());
}
ps = ps.Distinct().ToList();
FamilyPersonEntity[] families = repositoryFamilyPerson.DetachedEntities.Where(x => ps.AsQueryable().Contains(x.PersonId)).ToArray();
if (families != null && families.Length > 0)
{
data.CurrentPerson.FamilyTreePersons = families.Select(x => new FamilyPersonDisplayData(x)).ToArray();
}
return data;
}
@@ -346,6 +365,12 @@ namespace CloudBuilder.Master.Service
var currentPerson = data.CurrentPerson != null ? data.CurrentPerson.FamilyPerson.PersonId : null;
if (string.IsNullOrEmpty(currentPerson)) currentPerson = d.AncestorPersonId;
if (data.CurrentPerson != null && data.CurrentPerson.FamilyTreeSpouseMaps != null && data.CurrentPerson.FamilyTreeSpouseMaps.Length > 0)
data.CurrentPerson.FamilyTreeSpouseMaps.ToLookup(x => x.TreeId = d.TreeId);
if (data.CurrentPerson != null && data.CurrentPerson.FamilyTreeChildrenMaps != null && data.CurrentPerson.FamilyTreeChildrenMaps.Length > 0)
data.CurrentPerson.FamilyTreeChildrenMaps.ToLookup(x => x.TreeId = d.TreeId);
SaveSpouseMaps(data.CurrentPerson.FamilyPerson.PersonId, data.CurrentPerson.FamilyTreeSpouseMaps);
//SaveParentMaps(data.CurrentPerson.FamilyPerson.PersonId, data.CurrentPerson.FamilyTreeParentMaps);
SaveSonMaps(data.CurrentPerson.FamilyPerson.PersonId, data.CurrentPerson.FamilyTreeChildrenMaps);
@@ -12,11 +12,9 @@ namespace CloudBuilder.Master.Data
public class FamilyPersonData
{
public FamilyPersonDisplayData? FamilyPerson { get; set; }
public FamilyPersonDisplayData[]? FamilyTreePersons { get; set; }
public FamilyTreeSpouseMapViewEntity[]? FamilyTreeSpouseMaps { get; set; }
public FamilyTreeChildrenMapViewEntity[]? FamilyTreeParentMaps { get; set; }
public FamilyTreeChildrenMapViewEntity[]? FamilyTreeChildrenMaps { get; set; }
}
}
@@ -3,7 +3,7 @@
<Project>
<PropertyGroup>
<_PublishTargetUrl>C:\inetpub\wwwroot\dockwebapi2</_PublishTargetUrl>
<History>True|2026-08-26T05:34:12.3805892Z||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;</History>
<History>True|2026-09-02T02:58:16.8081797Z||;True|2026-08-26T13:34:12.3805892+08:00||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||;</History>
<LastFailureDetails />
</PropertyGroup>
</Project>
@@ -0,0 +1,574 @@
<template>
<dragModal v-model:open="visibleModel" width="85%" :title="title" :mask-closable="false" :fullscreen="fullscreen"
@cancel="onCancel">
<div v-if="hasPerson" class="ancestry-chart-wrap">
<div ref="containerRef" class="ancestry-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-if="!loading" 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 familyTreeService from "@/service/master/familyTreeService";
import { useLocalePolicy } from "@/hooks/useLocalePolicy";
import type { FamilyTreeData } from "@/data/auto/familyTreeData";
const props = defineProps({
open: { type: Boolean, default: false },
treeId: { type: String, default: "" },
title: { type: String, default: "祖谱系图" },
fullscreen: { type: Boolean, default: true },
});
const emit = defineEmits(["update:open", "cancel"]);
const visibleModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
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 undefined;
const t = (genderLabel(g) ?? "").toLowerCase();
return t.includes("男") || t.includes("male") || g === "M" || g === "1";
};
const loading = ref(false);
const data = ref<FamilyTreeData | null>(null);
const hasPerson = computed(() => !!data.value?.CurrentPerson?.FamilyPerson?.PersonId);
const rootPersonId = computed(() => data.value?.CurrentPerson?.FamilyPerson?.PersonId ?? "");
/** 全族索引:人员信息 / 夫妻关系(双向)/ 亲子关系 */
let personMap = new Map<string, any>();
let spousesOf = new Map<string, any[]>();
let childrenOf = new Map<string, any[]>();
/** 已展开(显示孩子)的节点 personId 集合 */
const expandedIds = ref<Set<string>>(new Set());
/** 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 nd = d?.data ?? {};
const male = isMale(nd.gender);
const theme = nd.kind === "self" ? "#fa8c16" : male === false ? "#eb2f96" : "#1890ff";
const ico = male === undefined ? "" : male ? "♂" : "♀";
const common = nd.commonName
? `<div class="ftc-sub">常用名:${esc(nd.commonName)}</div>`
: "";
const actionTag = nd.hasChildren
? `<div class="ftc-tag" style="color:#13c2c2; background:#13c2c21a;">${nd.expanded ? "点击折叠" : "点击展开"}</div>`
: "";
const clickable = nd.hasChildren ? "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(nd.name)}">${esc(nd.name)}</span>
</div>
${common}
<div class="ftc-footer">
<div class="ftc-tag" style="color:${theme}; background:${theme}1a;">${esc(nd.tag)}</div>
${actionTag}
</div>
</div>`;
};
/** 把人员信息写入索引(缺失字段才补全) */
const ensurePerson = (id: string, info: any) => {
if (!id) return;
const old = personMap.get(id);
if (!old) {
personMap.set(id, { ...info });
return;
}
if (!old.Gender && info.Gender) old.Gender = info.Gender;
if (!old.Surname && info.Surname) old.Surname = info.Surname;
if (!old.GenealogyName && info.GenealogyName) old.GenealogyName = info.GenealogyName;
if (!old.CommonName && info.CommonName) old.CommonName = info.CommonName;
};
/** 追加配偶(按 id 去重) */
const pushSpouse = (ownerId: string, sp: any) => {
if (!ownerId || !sp.id) return;
const list = spousesOf.get(ownerId) ?? [];
if (!list.some((x) => x.id === sp.id)) {
list.push(sp);
spousesOf.set(ownerId, list);
}
};
/** 由 FindAllByTreeId 的全族数据构建索引(三张表:人员 / 夫妻关系 / 亲子关系) */
const buildIndex = (resp: FamilyTreeData) => {
personMap = new Map();
spousesOf = new Map();
childrenOf = new Map();
// 1) 所有人员信息(含性别),优先作为人员卡片数据源
const persons = resp?.CurrentPerson?.FamilyTreePersons ?? [];
if (Array.isArray(persons)) {
persons.forEach((p: any) => {
if (!p?.PersonId) return;
personMap.set(p.PersonId, {
Surname: p.Surname,
GenealogyName: p.GenealogyName,
CommonName: p.CommonName,
Gender: p.Gender,
});
});
}
const ancestor = resp?.CurrentPerson?.FamilyPerson;
if (ancestor?.PersonId) {
personMap.set(ancestor.PersonId, {
Surname: ancestor.Surname,
GenealogyName: ancestor.GenealogyName,
CommonName: ancestor.CommonName,
Gender: ancestor.Gender,
});
}
// 2) 夫妻关系:多配偶按 PersonId 分组,双向索引(单向存储也能正确显示)
(resp?.CurrentPerson?.FamilyTreeSpouseMaps ?? []).forEach((s: any) => {
ensurePerson(s.PersonId, {
Surname: s.Surname,
GenealogyName: s.GenealogyName,
CommonName: s.CommonName,
Gender: s.Gender,
});
ensurePerson(s.SpouseId, {
Surname: s.SpouseSurname,
GenealogyName: s.SpouseGenealogyName,
CommonName: s.SpouseCommonName,
Gender: s.Gender,
});
// 双向索引:PersonId <-> SpouseId,同一配偶只记一次
pushSpouse(s.PersonId, {
id: s.SpouseId,
name: (s.SpouseSurname ?? "") + (s.SpouseGenealogyName ?? ""),
commonName: s.SpouseCommonName,
gender: s.Gender,
orderby: s.Orderby,
});
// 反向视图的本人性别不确定,渲染时从 personMap 取
pushSpouse(s.SpouseId, {
id: s.PersonId,
name: (s.Surname ?? "") + (s.GenealogyName ?? ""),
commonName: s.CommonName,
gender: undefined,
orderby: s.Orderby,
});
});
// 3) 亲子关系:按父/母 PersonId 分组,同一孩子只记一次
(resp?.CurrentPerson?.FamilyTreeChildrenMaps ?? []).forEach((c: any) => {
ensurePerson(c.PersonId, {
Surname: c.Surname,
GenealogyName: c.GenealogyName,
CommonName: c.CommonName,
});
ensurePerson(c.ChildrenId, {
Surname: c.ChildrenSurname,
GenealogyName: c.ChildrenGenealogyName,
CommonName: c.ChildrenCommonName,
Gender: c.Gender,
});
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,
gender: c.Gender,
relation: c.RelationType,
orderby: c.Orderby,
});
childrenOf.set(c.PersonId, list);
}
});
};
/** 构建图数据:递归一次性展示所有人员,祖先为根,配偶同行右侧,孩子列在配偶正下方 */
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 visited = new Set<string>();
const walk = (personId: string, depth: number, xBase: number) => {
if (visited.has(personId)) return;
visited.add(personId);
const info = personMap.get(personId) ?? {};
const isRoot = personId === root;
// 同级(配偶/孩子)按 Orderby 升序排列
const spouses = [...(spousesOf.get(personId) ?? [])].sort((a, b) => (a.orderby ?? 0) - (b.orderby ?? 0));
const children = [...(childrenOf.get(personId) ?? [])].sort((a, b) => (a.orderby ?? 0) - (b.orderby ?? 0));
const hasChildren = children.length > 0;
const x = xBase;
const y = cursor;
cursor += Y_STEP;
const expanded = expandedIds.value.has(personId);
nodes.push({
id: "n-" + personId,
style: { x, y },
data: {
kind: isRoot ? "self" : "member",
personId,
tag: isRoot ? "始祖" : "族员",
name: (info.Surname ?? "") + (info.GenealogyName ?? ""),
commonName: info.CommonName,
gender: info.Gender,
hasChildren,
expanded,
loaded: true,
},
});
// 配偶与本人同一行,水平间隔 200 对齐;
// 有孩子的配偶作为成员节点展开其子树,其余渲染为简单配偶卡片
const SPOUSE_GAP = 200;
spouses.forEach((s: any, i: number) => {
const sx = x + 210 + i * SPOUSE_GAP;
const hasSub = (childrenOf.get(s.id) ?? []).length > 0;
if (hasSub) {
walk(s.id, depth + 1, sx);
edges.push({ source: "n-" + personId, target: "n-" + s.id, data: { kind: "spouse" } });
} else {
const sid = "s-" + personId + "-" + i;
nodes.push({
id: sid,
style: { x: sx, y },
data: {
kind: "spouse",
tag: "配偶",
name: s.name,
commonName: s.commonName,
gender: s.gender ?? personMap.get(s.id)?.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;
children.forEach((c: any) => {
walk(c.id, depth + 1, childBaseX);
edges.push({ source: "n-" + personId, target: "n-" + c.id, data: { kind: "parent" } });
});
}
};
walk(root, 0, 0);
// 兜底:主树之外仍未被渲染、但名下还有孩子的人员(例如亲子记录只挂在配偶名下),
// 在主树右侧单独成列展示,保证全族人员一次性展示
let maxX = 0;
nodes.forEach((n: any) => {
if (n.style?.x > maxX) maxX = n.style.x;
});
const orphanX = maxX + 210;
personMap.forEach((_, pid) => {
if (visited.has(pid) || pid === root) return;
const children = [...(childrenOf.get(pid) ?? [])].sort((a, b) => (a.orderby ?? 0) - (b.orderby ?? 0));
if (children.length > 0) walk(pid, 0, orphanX);
});
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: [180, 96],
dx: -90,
dy: -48,
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 children = childrenOf.get(personId) ?? [];
if (children.length === 0) return;
toggleExpanded(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();
};
const loadTree = async () => {
const tid = props.treeId?.trim();
if (!tid) return;
loading.value = true;
try {
const resp = await familyTreeService.FindAllByTreeId(tid);
if (resp?.CurrentPerson?.FamilyPerson?.PersonId) {
data.value = resp;
buildIndex(resp);
// 递归一次性展示所有人员:有孩子的节点默认全部展开
const set = new Set<string>();
childrenOf.forEach((children, pid) => {
if (children.length > 0) set.add(pid);
});
expandedIds.value = set;
await initGraph();
}
} catch (error) {
console.log(error);
} finally {
loading.value = false;
}
};
watch(visibleModel, async (v) => {
if (v) {
data.value = null;
await loadTree();
} else {
destroyGraph();
}
});
onBeforeUnmount(() => destroyGraph());
const onCancel = () => {
visibleModel.value = false;
emit("cancel");
};
</script>
<style>
.ancestry-chart-wrap {
position: relative;
height: 100%;
min-height: 480px;
}
.ancestry-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 节点卡片样式(与谱系图一致,使用独立前缀避免污染全局) */
.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>
@@ -1,8 +1,18 @@
<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>
<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>
@@ -81,7 +91,7 @@ const renderCard = (d: any) => {
: "";
const clickable = data.hasChildren || !data.loaded ? "ftc-clickable" : "";
return `
<div class="ftc-card ${clickable}" style="border-top: 3px solid ${theme};">
<div class="ftc-card ftc-card-lg ${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>
@@ -94,6 +104,9 @@ const renderCard = (d: any) => {
</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) {
@@ -101,8 +114,8 @@ const describePerson = (personId: string): any => {
if (!dp?.FamilyPerson) return null;
return {
person: dp.FamilyPerson,
spouses: dp.FamilyTreeSpouseMaps ?? [],
children: dp.FamilyTreeChildrenMaps ?? [],
spouses: [...(dp.FamilyTreeSpouseMaps ?? [])].sort(byOrder),
children: [...(dp.FamilyTreeChildrenMaps ?? [])].sort(byOrder),
loaded: true,
};
}
@@ -110,8 +123,8 @@ const describePerson = (personId: string): any => {
if (loaded) {
return {
person: loaded.person,
spouses: loaded.spouses ?? [],
children: loaded.children ?? [],
spouses: [...(loaded.spouses ?? [])].sort(byOrder),
children: [...(loaded.children ?? [])].sort(byOrder),
loaded: true,
};
}
@@ -125,7 +138,13 @@ const buildGraphData = () => {
const root = rootPersonId.value;
if (!root) return { nodes, edges };
const Y_STEP = 116;
const CARD_WIDTH = 360;
const CARD_HEIGHT = 192;
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) => {
@@ -156,13 +175,12 @@ const buildGraphData = () => {
},
});
// 配偶与本人同一行,水平间隔 200 对齐
const SPOUSE_GAP = 200;
// 配偶与本人同一行,水平间隔 X_STEP 对齐
desc.spouses.forEach((s: any, i: number) => {
const sid = "s-" + personId + "-" + i;
nodes.push({
id: sid,
style: { x: x + 210 + i * SPOUSE_GAP, y },
style: { x: xBase + X_STEP + i * SPOUSE_GAP, y },
data: {
kind: "spouse",
tag: "配偶",
@@ -177,8 +195,8 @@ const buildGraphData = () => {
});
if (expanded) {
// 孩子列与本人第一行的配偶列对齐(x + 210),即孩子位于配偶正下方,竖直跟第一行对齐
const childBaseX = x + 210;
// 孩子列与本人第一行的配偶列对齐(xBase + X_STEP),即孩子位于配偶正下方,竖直跟第一行对齐
const childBaseX = xBase + X_STEP;
desc.children.forEach((c: any) => {
const childId = c.ChildrenId;
@@ -193,7 +211,7 @@ const buildGraphData = () => {
personId: childId,
tag: c.RelationType ? relationLabel(c.RelationType) : "子女",
name: (c.ChildrenSurname ?? "") + (c.ChildrenGenealogyName ?? ""),
commonName: c.CommonName,
commonName: c.ChildrenCommonName,
gender: c.Gender,
hasChildren: false,
expanded: false,
@@ -214,6 +232,28 @@ const buildGraphData = () => {
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();
@@ -233,14 +273,16 @@ const initGraph = async () => {
graph = new Graph({
container: containerRef.value,
autoFit: "view",
// 内容小于视口时不缩放(卡片保持原始大小),内容溢出时才适配
autoFit: { type: "view", options: { when: "overflow", direction: "both" } },
zoomRange: [MIN_ZOOM, 10],
data: { nodes, edges },
node: {
type: "html",
style: {
size: [180, 96],
dx: -90,
dy: -48,
size: [360, 192],
dx: -180,
dy: -96,
// 官方文档推荐函数形式,类型声明仅支持 string/HTMLElement,故断言
innerHTML: renderCard as any,
},
@@ -276,6 +318,10 @@ const initGraph = async () => {
}
});
await graph.render();
// 世代过多导致 fitView 缩得太小时,放大到最小显示比例,保证卡片可读
if (graph.getZoom() < MIN_ZOOM) {
await graph.zoomTo(MIN_ZOOM);
}
};
const toggleExpanded = (personId: string) => {
@@ -322,20 +368,61 @@ watch(
onBeforeUnmount(() => destroyGraph());
const onCancel = () => emit("cancel");
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: 72vh;
min-height: 480px;
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;
@@ -351,6 +438,29 @@ const onCancel = () => emit("cancel");
user-select: none;
}
/* 谱系图大卡片(节点尺寸放大 2 倍,内部文字同步放大) */
.ftc-card-lg {
padding: 12px 16px;
}
.ftc-card-lg .ftc-ico {
font-size: 24px;
}
.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;
@@ -95,7 +95,7 @@
v-model:value="data.CurrentPerson.FamilyPerson.PersonId"
@search="onModalCurrentPerson" />
<a-button type="primary" :disabled="!hasData" :icon="h(ApartmentOutlined)"
@click.prevent="">祖谱系图</a-button>
@click.prevent="onShowAncestryChart">祖谱系图</a-button>
</a-input-group>
</a-col>
</a-row>
@@ -314,6 +314,7 @@
</dragModal>
<FamilyTreeChartModal v-model:open="chartVisible" :data="data" :loaded-nodes="loadedNodes" title="谱系图"
@select-child="onChartSelectChild" />
<AncestryTreeChartModal v-model:open="ancestryChartVisible" :tree-id="treeId" title="祖谱系图" />
</a-spin>
</template>
<script lang="ts" setup>
@@ -328,6 +329,7 @@ import dragModal from "@/components/basic/DragModal.vue"
import FamilyTreeEnquiry from "./FamilyTreeEnquiry.vue";
import FamilyPersonEnquiry from "./FamilyPersonEnquiry.vue";
import FamilyTreeChartModal from "@/components/master/FamilyTreeChartModal.vue";
import AncestryTreeChartModal from "@/components/master/AncestryTreeChartModal.vue";
import { FamilyTreeData } from "@/data/auto/familyTreeData";
import { FamilyTreeSpouseMapViewEntity } from "@/data/auto/familyTreeSpouseMapViewEntity";
import { FamilyTreeChildrenMapViewEntity } from "@/data/auto/familyTreeChildrenMapViewEntity";
@@ -359,6 +361,7 @@ const canCreate = ref<boolean>(false);
const showModalType = ref<number>(0);
const chartVisible = ref<boolean>(false);
const ancestryChartVisible = ref<boolean>(false);
const data = ref<FamilyTreeData>(new FamilyTreeData());
const hasData = ref<boolean>(false);
@@ -602,6 +605,11 @@ const onShowChart = (() => {
chartVisible.value = true;
})
const onShowAncestryChart = (() => {
if (!hasData.value || !treeId.value) return;
ancestryChartVisible.value = true;
})
/** 谱系图中已加载的族员节点(personId -> { person, spouses, children }),不清空原 data,只追加 */
const loadedNodes = ref<Record<string, any>>({});