884 lines
31 KiB
Vue
884 lines
31 KiB
Vue
<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, onBeforeUnmount, provide, ref, watch } from "vue";
|
||
import { usePolicyOptions } from "@/composables/usePolicyOptions";
|
||
import { request } from "@/utils/request";
|
||
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>;
|
||
/** 主系脊(始祖→当前族人的直连祖先链,含两端):默认展开恒开,保证当前族人可达可见 */
|
||
spine: Set<string>;
|
||
/** 各人世代号(始祖=0,按亲子视图自顶向下 BFS):±5 代窗口据此判定 */
|
||
genOf: Map<string, number>;
|
||
}
|
||
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] };
|
||
}
|
||
|
||
// ===== 全系图默认展开口径:主系脊恒开 + 当前族人 ±5 代窗口展开,其余折叠待点击懒展开 =====
|
||
const MAIN_LINE_UP = 5;
|
||
const MAIN_LINE_DOWN = 5;
|
||
/** 一键展开全部(大谱会变卡,由用户自担);关闭回到 ±5 代窗口默认 */
|
||
const expandAll = ref(false);
|
||
|
||
/** 依当前口径算展开集:expandAll=全展开;否则主系脊 ∪ 世代窗口内节点 */
|
||
function computeExpanded(): Record<string, boolean> {
|
||
const index = fullIndex.value;
|
||
const next: Record<string, boolean> = {};
|
||
if (!index) {
|
||
return next;
|
||
}
|
||
if (index.rootId) {
|
||
next[index.rootId] = true;
|
||
}
|
||
const curGen = index.genOf.get(index.currentId);
|
||
index.childrenOf.forEach((_list: any[], parentId: string) => {
|
||
if (expandAll.value || index.spine.has(parentId)) {
|
||
next[parentId] = true;
|
||
return;
|
||
}
|
||
const g = index.genOf.get(parentId);
|
||
if (curGen !== undefined && g !== undefined && g >= curGen - MAIN_LINE_UP && g <= curGen + MAIN_LINE_DOWN) {
|
||
next[parentId] = true;
|
||
}
|
||
});
|
||
return next;
|
||
}
|
||
|
||
function toggleExpandAll(): void {
|
||
expandAll.value = !expandAll.value;
|
||
expanded.value = computeExpanded();
|
||
}
|
||
|
||
// ===== 缩放与布局方向(弹窗右上控制条)=====
|
||
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;
|
||
}
|
||
|
||
// ===== 控制条可拖拽:按住顶部握把行挪位(拖拽热区只在握把,不碰动作钮),
|
||
// ctrlOffset 为整条相对默认右上锚点的位移 =====
|
||
const ctrlOffset = ref({ x: 0, y: 0 });
|
||
let dragStart: { tx: number; ty: number; ox: number; oy: number } | null = null;
|
||
|
||
function onCtrlTouchStart(e: TouchEvent): void {
|
||
const t0 = e.touches?.[0];
|
||
if (!t0) {
|
||
return;
|
||
}
|
||
dragStart = { tx: t0.clientX, ty: t0.clientY, ox: ctrlOffset.value.x, oy: ctrlOffset.value.y };
|
||
}
|
||
|
||
function onCtrlTouchMove(e: TouchEvent): void {
|
||
const t0 = e.touches?.[0];
|
||
if (!dragStart || !t0) {
|
||
return;
|
||
}
|
||
const sys = uni.getSystemInfoSync();
|
||
const barW = uni.upx2px(88);
|
||
// 握把 + 动作钮各 76 + 分隔线;全系图多一枚「展开全部」钮,高度随之 +76
|
||
const btnCount = props.mode === "full" ? 5 : 4;
|
||
const barH = uni.upx2px(76 + btnCount * 76 + 4);
|
||
const edge = uni.upx2px(16);
|
||
// 默认锚点(top/right 定位)换算成 left/top 基准,再按屏幕边界夹取
|
||
const baseLeft = sys.windowWidth - uni.upx2px(24) - barW;
|
||
const baseTop = uni.upx2px(120) + (sys.safeAreaInsets?.top || 0);
|
||
const minX = edge - baseLeft;
|
||
const maxX = sys.windowWidth - edge - barW - baseLeft;
|
||
const minY = edge + uni.upx2px(110) + (sys.safeAreaInsets?.top || 0) - baseTop;
|
||
const maxY = sys.windowHeight - edge - barH - baseTop;
|
||
ctrlOffset.value = {
|
||
x: Math.min(Math.max(dragStart.ox + t0.clientX - dragStart.tx, minX), maxX),
|
||
y: Math.min(Math.max(dragStart.oy + t0.clientY - dragStart.ty, minY), maxY),
|
||
};
|
||
}
|
||
|
||
function onCtrlTouchEnd(): void {
|
||
dragStart = null;
|
||
}
|
||
|
||
// ===== 双指捏合缩放:画布触摸层两指距离比换算 zoom(5% 步进,比 ± 钮细腻)=====
|
||
let pinchStartDist = 0;
|
||
let pinchStartZoom = 1;
|
||
|
||
function touchGap(t0: Touch, t1: Touch): number {
|
||
return Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||
}
|
||
|
||
function onChartTouchStart(e: TouchEvent): void {
|
||
if (e.touches?.length === 2) {
|
||
pinchStartDist = touchGap(e.touches[0], e.touches[1]);
|
||
pinchStartZoom = zoom.value;
|
||
} else {
|
||
pinchStartDist = 0;
|
||
}
|
||
}
|
||
|
||
function onChartTouchMove(e: TouchEvent): void {
|
||
if (e.touches?.length !== 2 || pinchStartDist <= 0) {
|
||
return;
|
||
}
|
||
const ratio = touchGap(e.touches[0], e.touches[1]) / pinchStartDist;
|
||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(pinchStartZoom * ratio * 20) / 20));
|
||
if (next !== zoom.value) {
|
||
// H5:捏合期间拦掉默认滚动,避免双指被 scroll-view 当成拖动
|
||
e.preventDefault?.();
|
||
zoom.value = next;
|
||
}
|
||
}
|
||
|
||
function onChartTouchEnd(e: TouchEvent): void {
|
||
if (!e.touches || e.touches.length < 2) {
|
||
pinchStartDist = 0;
|
||
}
|
||
}
|
||
|
||
// ===== H5 鼠标拖拽平移:scroll-view 桌面端只能滚轮/触控板滚,按住拖动不动,补一套 mousedown→改 scroll-left/top =====
|
||
// 走受控 scroll-left/scroll-top(uni 官方属性,跨版本稳):拖动中改这两个 ref 让 scroll-view 滚,
|
||
// 非拖动的滚轮/触摸由 @scroll 回填 ref(panning 时不回填,避免和手打架)。
|
||
const scrollLeft = ref(0);
|
||
const scrollTop = ref(0);
|
||
const isPanning = ref(false);
|
||
let panning = false;
|
||
let panMoved = false;
|
||
let panStartX = 0;
|
||
let panStartY = 0;
|
||
let panStartLeft = 0;
|
||
let panStartTop = 0;
|
||
// 拖拽刚结束的时刻:node 用它丢弃「拖完松手那一下」误触发的 tap
|
||
let panEndedAt = 0;
|
||
|
||
function onPanStart(e: MouseEvent): void {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
// 只认鼠标左键;右键/中键交回浏览器,触摸平移走原生滚动
|
||
if (e.button !== 0) {
|
||
return;
|
||
}
|
||
panning = true;
|
||
panMoved = false;
|
||
isPanning.value = true;
|
||
panStartX = e.clientX;
|
||
panStartY = e.clientY;
|
||
panStartLeft = scrollLeft.value;
|
||
panStartTop = scrollTop.value;
|
||
// 不能 preventDefault:它会取消后续原生 click,卡片 @tap 再也收不到(float-add-btn 同款教训);
|
||
// 文本选中与图片拖影改由 CSS user-select:none + -webkit-user-drag:none 拦
|
||
window.addEventListener("mousemove", onPanMove);
|
||
window.addEventListener("mouseup", onPanEnd);
|
||
}
|
||
|
||
function onPanMove(e: MouseEvent): void {
|
||
if (!panning) {
|
||
return;
|
||
}
|
||
const dx = e.clientX - panStartX;
|
||
const dy = e.clientY - panStartY;
|
||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
||
panMoved = true;
|
||
}
|
||
// 抓住内容反向拖:鼠标右移→视口左移(scrollLeft 减小)
|
||
scrollLeft.value = Math.max(0, panStartLeft - dx);
|
||
scrollTop.value = Math.max(0, panStartTop - dy);
|
||
}
|
||
|
||
function onPanEnd(): void {
|
||
if (!panning) {
|
||
return;
|
||
}
|
||
panning = false;
|
||
isPanning.value = false;
|
||
if (typeof window !== "undefined") {
|
||
window.removeEventListener("mousemove", onPanMove);
|
||
window.removeEventListener("mouseup", onPanEnd);
|
||
}
|
||
if (panMoved) {
|
||
panEndedAt = Date.now();
|
||
}
|
||
}
|
||
|
||
/** 滚轮/触摸滚动时把真实偏移同步回受控值(拖动中跳过,避免覆盖刚算出的目标位置) */
|
||
function onChartScroll(e: any): void {
|
||
if (panning) {
|
||
return;
|
||
}
|
||
scrollLeft.value = e?.detail?.scrollLeft ?? scrollLeft.value;
|
||
scrollTop.value = e?.detail?.scrollTop ?? scrollTop.value;
|
||
}
|
||
|
||
/** 拖拽平移刚结束的一瞬为真:node 用它丢弃误触 tap */
|
||
function didPanDrag(): boolean {
|
||
return Date.now() - panEndedAt < 250;
|
||
}
|
||
|
||
// 拖拽中途弹窗被关(组件卸载)也要摘掉 window 监听,避免泄漏与卸载后仍改 ref
|
||
onBeforeUnmount(() => {
|
||
if (typeof window !== "undefined") {
|
||
window.removeEventListener("mousemove", onPanMove);
|
||
window.removeEventListener("mouseup", onPanEnd);
|
||
}
|
||
});
|
||
|
||
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,
|
||
didPanDrag,
|
||
});
|
||
|
||
/** 全树:从 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;
|
||
}
|
||
// 主系脊:visited 即「当前族人 + 其历代父辈直至始祖」,默认展开据此恒开
|
||
const spine = new Set(visited);
|
||
|
||
// 世代号:自始祖按亲子视图 BFS(一人多父取首父世代,图以主承担父渲染子树,够用且稳)
|
||
const genOf = new Map<string, number>();
|
||
if (rootId) {
|
||
genOf.set(rootId, 0);
|
||
const queue: string[] = [rootId];
|
||
for (let i = 0; i < queue.length; i++) {
|
||
const id = queue[i];
|
||
const g = genOf.get(id) as number;
|
||
(childrenOf.get(id) ?? []).forEach((c: any) => {
|
||
if (!genOf.has(c.id)) {
|
||
genOf.set(c.id, g + 1);
|
||
queue.push(c.id);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
return { rootId, personMap, spousesOf, childrenOf, parentsOf, currentId, lineageChildOf, spine, genOf };
|
||
}
|
||
|
||
async function loadFull(): Promise<void> {
|
||
const treeId = props.treeId.trim();
|
||
if (!treeId) {
|
||
fullIndex.value = null;
|
||
return;
|
||
}
|
||
try {
|
||
loading.value = true;
|
||
// 整谱一次性下发(大谱数千族员),体量远超常规接口,走生成 service 拿不到单次超时会被全局 20s 掐断
|
||
// 报「请求错误:未知」,故这里直连请求层并放宽到 90s(后端已同步瘦身,只下发卡片渲染所需列)
|
||
const resp = await request<FamilyTreeData>({
|
||
url: "/FamilyTree/FindAllByTreeId?treeId=" + treeId,
|
||
method: "post",
|
||
data: treeId,
|
||
timeout: 90000,
|
||
});
|
||
if (resp?.CurrentPerson) {
|
||
fullIndex.value = buildFullIndex(resp);
|
||
}
|
||
else {
|
||
fullIndex.value = null;
|
||
}
|
||
}
|
||
catch (error) {
|
||
console.error(error);
|
||
fullIndex.value = null;
|
||
}
|
||
finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 弹窗打开:重置展开态;full 默认只铺主系脊+当前族人±5代窗口(旁系/更深世代折叠懒展开,可一键全展开),sub 仅展开根待懒加载 */
|
||
watch(
|
||
() => props.visible,
|
||
(on) => {
|
||
if (!on) {
|
||
return;
|
||
}
|
||
expanded.value = {};
|
||
if (props.mode === "full") {
|
||
expandAll.value = false;
|
||
loadFull().then(() => {
|
||
// 默认只铺主系脊与当前族人 ±5 代窗口,旁系/更深世代折叠,靠 node 点击懒展开(避免整谱全展开卡死 H5)
|
||
expanded.value = computeExpanded();
|
||
});
|
||
}
|
||
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>
|
||
<!-- 退出钮按用户要求改为 × 关闭图标(灰底圆钮) -->
|
||
<view class="ftc__close" @tap="close">
|
||
<u-icon name="close" size="28" color="#5c626b" />
|
||
</view>
|
||
</view>
|
||
<!-- 悬浮控制条:放大/缩小/百分比复位/横纵布局切换(对齐旧图 G6 工具钮);
|
||
会挡树图内容,按住顶部握把行可拖拽挪位;动作钮只响应单击,与拖拽互不干扰 -->
|
||
<view class="ftc__ctrl" :style="{ transform: `translate(${ctrlOffset.x}px, ${ctrlOffset.y}px)` }">
|
||
<view class="ftc__ctrl-grip" @touchstart="onCtrlTouchStart" @touchmove="onCtrlTouchMove"
|
||
@touchend="onCtrlTouchEnd" @touchcancel="onCtrlTouchEnd">
|
||
<text class="ftc__ctrl-grip-dot">⠿</text>
|
||
</view>
|
||
<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>
|
||
<!-- 全系图专用:一键展开全部 / 回到 ±5 代窗口默认(sub 懒加载模式无整谱概念,不显示) -->
|
||
<view v-if="props.mode === 'full'" class="ftc__ctrl-btn"
|
||
:class="{ 'ftc__ctrl-btn--active': expandAll }" @tap="toggleExpandAll">
|
||
<text class="ftc__ctrl-glyph">{{ expandAll ? '⊟' : '⊞' }}</text>
|
||
</view>
|
||
</view>
|
||
<!-- 画布:双指捏合缩放(touch 层只识别两指,单指滚动不受影响);H5 另支持鼠标按住拖拽平移(@mousedown 走模板绑定,见 float-add-btn 同款) -->
|
||
<scroll-view class="ftc__scroll" :class="{ 'ftc__scroll--panning': isPanning }" scroll-x scroll-y
|
||
:show-scrollbar="true" :scroll-left="scrollLeft" :scroll-top="scrollTop" @scroll="onChartScroll"
|
||
@mousedown="onPanStart" @touchstart="onChartTouchStart" @touchmove="onChartTouchMove"
|
||
@touchend="onChartTouchEnd" @touchcancel="onChartTouchEnd">
|
||
<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;
|
||
/* App 端状态栏透明且不占位,用 uni 注入的 --status-bar-height 避让;H5 用 safe-area */
|
||
/* #ifdef APP-PLUS */
|
||
padding: calc(28rpx + var(--status-bar-height, 0px)) 32rpx 28rpx;
|
||
/* #endif */
|
||
/* #ifndef APP-PLUS */
|
||
padding: calc(28rpx + env(safe-area-inset-top)) 32rpx 28rpx;
|
||
/* #endif */
|
||
background-color: #ffffff;
|
||
border-bottom: 2rpx solid #f2f3f5;
|
||
}
|
||
|
||
.ftc__title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #1f2329;
|
||
}
|
||
|
||
.ftc__close {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
border-radius: 50%;
|
||
background-color: #f2f3f5;
|
||
}
|
||
|
||
/* 悬浮控制条:右侧竖排,白底圆角轻阴影;整条可拖拽(见 ctrlOffset) */
|
||
.ftc__ctrl {
|
||
position: absolute;
|
||
/* #ifdef APP-PLUS */
|
||
top: calc(120rpx + var(--status-bar-height, 0px));
|
||
/* #endif */
|
||
/* #ifndef APP-PLUS */
|
||
top: calc(120rpx + env(safe-area-inset-top));
|
||
/* #endif */
|
||
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);
|
||
}
|
||
|
||
/* 顶部握把行:拖拽热区(touch-action:none 只加在这里,动作钮手势不受影响);
|
||
与动作钮等高,手指易按住 */
|
||
.ftc__ctrl-grip {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 76rpx;
|
||
border-bottom: 2rpx solid #f2f3f5;
|
||
cursor: move;
|
||
touch-action: none;
|
||
}
|
||
|
||
.ftc__ctrl-grip-dot {
|
||
font-size: 22rpx;
|
||
line-height: 1;
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 展开全部态:青底白字,与默认「±5 代窗口」折叠态区分(对齐 node 动作钮青配色) */
|
||
.ftc__ctrl-btn--active {
|
||
.ftc__ctrl-glyph {
|
||
color: #ffffff;
|
||
}
|
||
|
||
background-color: #13c2c2;
|
||
border-bottom-color: #13c2c2;
|
||
}
|
||
|
||
.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;
|
||
|
||
/* H5 鼠标拖拽平移:手型光标 + 禁选(图是示意不是文本,拖动时不选中卡内文字/不拖出图片幽灵) */
|
||
cursor: grab;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
.ftc__scroll--panning {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
/* 拖拽平移时不把头像拖出原生图片幽灵(preventDefault 会误伤 @tap,改由 CSS 拦) */
|
||
.ftc__scroll :deep(img) {
|
||
-webkit-user-drag: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.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>
|