Files
Docker/uniapp/youjiala/src/components/family-tree-chart/family-tree-chart.vue
T

884 lines
31 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.
<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>