Files
jiapuapp/pages/tree/t01-tree-overview.vue
T
2026-07-30 07:38:09 +08:00

1488 lines
41 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.
<!-- 页面编号T-01用途纵向世系树阅读同代横向提示空态与失败态 -->
<template>
<view
class="tree-page"
:class="{
'tree-state--tree': treeState === 'tree',
'tree-state--landscape': treeState === 'landscape',
'tree-state--empty': treeState === 'empty',
'tree-state--error': treeState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="tree-page__header">
<PageHeader title="树状图" action="世系谱" @action="toPedigree" />
</view>
<view v-if="treeState !== 'loading'" class="tree-toolbar">
<view class="tree-toolbar__title">
<text>世系成员</text>
<text>{{ generationRangeLabel }}</text>
</view>
<view class="tree-toolbar__actions">
<text
v-if="treeState === 'tree' && treeHasDrifted && selected"
class="tree-recenter"
role="button"
aria-label="回到当前成员"
hover-class="tree-action--pressed"
@click="recenterSelectedMember"
>回到当前</text
>
<text @click="treeState = 'landscape'">阅读提示</text>
<text @click="toRank">调整排行</text>
</view>
</view>
<view
class="tree-stage"
:class="{ 'tree-stage--lineage': treeState === 'tree' }"
>
<view
v-if="treeState === 'tree'"
class="generation-rail"
:style="generationRailStyle"
>
<view
v-for="row in generationRows"
:key="row.generation"
class="generation-band"
:style="generationBandStyle(row)"
>
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view class="generation-band__copy">
<text>{{ row.label }}</text>
<text>{{ row.summary }}</text>
</view>
</view>
</view>
<view v-if="treeState === 'tree'" class="lineage-pan-cue">
<text>同代分支可左右查看</text>
</view>
<scroll-view
class="tree-scroll"
:class="{ 'tree-scroll--lineage': treeState === 'tree' }"
scroll-x
:scroll-left="treeState === 'tree' ? treeScrollLeft : 0"
:show-scrollbar="false"
:style="treeScrollStyle"
@scroll="handleTreeScroll"
>
<view
class="tree-canvas"
:class="{ 'tree-canvas--state': treeState !== 'tree' }"
:style="treeState === 'tree' ? treeMetricsStyle : undefined"
>
<AppLoading
v-if="treeState === 'loading'"
text="正在整理世系"
description="请稍候,正在准备家谱成员关系。"
/>
<template v-else-if="treeState === 'tree'">
<view
v-for="connector in lineageConnectors"
:key="connector.id"
class="lineage-connector"
:class="{
'lineage-connector--spouse': connector.kind === 'spouse',
}"
:style="connector.style"
/>
<view
v-for="member in layoutMembers"
:key="member.id"
:id="`tree-member-${member.id}`"
class="member-node"
:class="{
'member-node--selected': selected?.id === member.id,
'member-node--spouse': Boolean(member.spouseOf),
}"
:style="nodeGridStyle(member)"
@click="openMemberPanel(member)"
>
<view class="member-node__surface">
<view class="member-node__portrait">
<image
class="member-node__avatar"
src="/static/assets/foundation/transparent/tab-profile.png"
mode="aspectFit"
/>
</view>
<image
class="member-node__divider"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<view class="member-node__copy">
<text class="node-name">{{ member.name }}</text>
<text class="node-relation">{{
nodeRelationText(member)
}}</text>
<text class="node-years">{{ member.years }}</text>
</view>
</view>
</view>
</template>
<view v-else class="tree-state-card">
<image
class="tree-state-card__skin"
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view class="tree-state-card__content">
<text class="tree-state-card__eyebrow">{{
stateCopy.eyebrow
}}</text>
<text class="tree-state-card__title">{{ stateCopy.title }}</text>
<text class="tree-state-card__copy">{{ stateCopy.copy }}</text>
<view class="tree-state-card__action" @click="handleStateAction">
<image
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
mode="aspectFit"
/>
<text>{{ stateCopy.action }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<view
v-if="memberActionPanelVisible"
class="member-action-panel-layer"
@click="closeMemberActionPanel"
>
<view
class="member-action-panel"
role="dialog"
aria-modal="true"
:aria-label="selected ? `管理${selected.name}` : '管理人物'"
@click.stop
>
<image
class="member-action-panel__paper"
src="/static/assets/modules/tree/transparent/t01-action-panel-paper-v3.png"
mode="scaleToFill"
/>
<view class="member-action-panel__head">
<view
v-if="selected"
class="member-action-profile"
role="button"
:aria-label="`查看${selected.name}的资料`"
@click="toMember"
>
<image
class="member-action-profile__avatar"
src="/static/assets/modules/tree/transparent/t01-member-medallion-v2.png"
mode="aspectFit"
/>
<view class="member-action-profile__copy">
<text>{{ selected.name }}</text>
<text> {{ selected.generation }} · {{ selected.relation }}</text>
<text>点击查看人物资料</text>
</view>
</view>
<view
class="member-action-panel__close-button"
role="button"
aria-label="关闭人物操作"
@click="closeMemberActionPanel"
>
<image
class="member-action-panel__close"
src="/static/assets/modules/tree/transparent/t01-action-close-v2.png"
mode="aspectFit"
/>
</view>
</view>
<scroll-view class="member-action-panel__content" scroll-y>
<view class="member-action-panel__scroll-body">
<image
class="member-action-panel__relationship-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view class="member-action-map">
<image
class="member-action-map__graph"
src="/static/assets/modules/tree/transparent/t01-relation-map-v5.png"
mode="scaleToFill"
/>
<view
v-for="action in relationActions"
:key="action.key"
class="member-action-map__item"
:class="`member-action-map__item--${action.slot}`"
role="button"
:aria-label="action.label"
@click="openMemberAction(action)"
>
<image
class="member-action-map__item-frame"
src="/static/assets/modules/tree/transparent/t01-relation-button-frame-v4.png"
mode="scaleToFill"
/>
<image
src="/static/assets/modules/tree/transparent/t01-relation-marker-v2.png"
mode="aspectFit"
/>
<text>{{ action.shortLabel }}</text>
<image
class="member-action-map__arrow"
src="/static/assets/modules/tree/transparent/t01-action-arrow-v2.png"
mode="aspectFit"
/>
</view>
</view>
<view class="member-action-grid">
<view
v-for="action in managementActions"
:key="action.key"
class="member-action-grid__item"
role="button"
:aria-label="action.label"
@click="openMemberAction(action)"
>
<image
src="/static/assets/modules/tree/transparent/t01-management-marker-v2.png"
mode="aspectFit"
/>
<text>{{ action.label }}</text>
<image
class="member-action-grid__arrow"
src="/static/assets/modules/tree/transparent/t01-action-arrow-v2.png"
mode="aspectFit"
/>
</view>
</view>
<text class="member-action-panel__hint"
>所有操作都将作用于当前人物</text
>
</view>
</scroll-view>
</view>
</view>
<AppDialog
:visible="unavailableActionVisible"
eyebrow="服务状态"
:title="unavailableAction?.label || '当前操作'"
:message="
unavailableAction?.unavailableCopy || '该操作暂未接入可靠服务合同。'
"
confirm-text="我知道了"
@confirm="unavailableActionVisible = false"
@cancel="unavailableActionVisible = false"
/>
</view>
</template>
<script setup>
import { computed, nextTick, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const treeState = ref("loading");
const selected = ref(null);
const memberActionPanelVisible = ref(false);
const unavailableActionVisible = ref(false);
const unavailableAction = ref(null);
const treeScrollLeft = ref(90);
const currentTreeScrollLeft = ref(90);
const centeredTreeScrollLeft = ref(90);
const treeHasDrifted = ref(false);
let ignoreTreeScroll = false;
let recenterTimer = null;
const treeRequestController = createRequestController();
let treeLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
const GRID_UNIT = 5;
const NODE_HALF_WIDTH = 80;
const NODE_HALF_HEIGHT = 94;
const FAMILY_LINK_OFFSET = 18;
const MEMBER_GAP = 178;
const GENERATION_GAP = 260;
const members = ref([]);
const memberActions = Object.freeze([
{
key: "VIEW_PROFILE",
label: "查看资料",
group: "MANAGEMENT",
routeKey: "T03",
},
{
key: "ADD_FATHER",
label: "添加父亲",
shortLabel: "父亲",
group: "RELATION",
slot: "top",
routeKey: "T04",
relationType: "FATHER",
},
{
key: "ADD_MOTHER",
label: "添加母亲",
shortLabel: "母亲",
group: "RELATION",
slot: "left-top",
routeKey: "T04",
relationType: "MOTHER",
},
{
key: "ADD_SPOUSE",
label: "添加配偶",
shortLabel: "配偶",
group: "RELATION",
slot: "right-top",
routeKey: "T04",
relationType: "SPOUSE",
},
{
key: "ADD_SIBLING",
label: "添加兄弟姐妹",
shortLabel: "兄弟姐妹",
group: "RELATION",
slot: "left-bottom",
routeKey: "T04",
relationType: "SIBLING",
},
{
key: "ADJUST_RANK",
label: "调整排行",
group: "MANAGEMENT",
routeKey: "T06",
mode: "rank",
},
{
key: "ADD_SON",
label: "添加儿子",
shortLabel: "儿子",
group: "RELATION",
slot: "bottom",
routeKey: "T04",
relationType: "SON",
},
{
key: "ADD_DAUGHTER",
label: "添加女儿",
shortLabel: "女儿",
group: "RELATION",
slot: "right-bottom",
routeKey: "T04",
relationType: "DAUGHTER",
},
{
key: "BIND_INVITE",
label: "邀请绑定",
group: "MANAGEMENT",
unavailableCopy:
"邀请签发、目标身份查找、绑定 mutation 和结果查询尚无可靠合同;当前不会借邀请码或普通入谱申请替代。",
},
{
key: "EDIT_PROFILE",
label: "编辑信息",
group: "MANAGEMENT",
routeKey: "T05",
},
]);
const relationActions = computed(() =>
memberActions.filter((action) => action.group === "RELATION"),
);
const managementActions = computed(() =>
memberActions.filter((action) => action.group === "MANAGEMENT"),
);
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
const nodeRelationText = (member) => {
const branch = String(member.branch || "").replace(/字辈$/, "");
return [member.relation, branch].filter(Boolean).join(" · ");
};
const layoutMembers = computed(() => {
const memberById = new Map(
members.value.map((member) => [String(member.id), member]),
);
const compareMembers = (left, right) =>
Number(left.generation) - Number(right.generation) ||
String(left.id).localeCompare(String(right.id));
const spousesByPersonId = new Map();
members.value.forEach((member) => {
const partnerId = String(member.spouseOf || "");
if (partnerId && memberById.has(partnerId)) {
spousesByPersonId.set(partnerId, member);
}
});
const primaryMembers = members.value
.filter((member) => !member.spouseOf)
.sort(compareMembers);
const primaryById = new Map(
primaryMembers.map((member) => [String(member.id), member]),
);
const childrenByParent = new Map();
primaryMembers.forEach((member) => {
const parentId = String(member.parentId || "");
if (!parentId || !primaryById.has(parentId)) return;
const children = childrenByParent.get(parentId) || [];
children.push(member);
childrenByParent.set(parentId, children);
});
childrenByParent.forEach((children) => children.sort(compareMembers));
// A family owns one contiguous horizontal span. Parents are centered in
// that span, so a later generation cannot pull a branch beneath another one.
const subtreeWidthById = new Map();
const measureSubtree = (member, ancestors = new Set()) => {
const id = String(member.id);
if (subtreeWidthById.has(id)) return subtreeWidthById.get(id);
if (ancestors.has(id)) return 1;
const nextAncestors = new Set(ancestors);
nextAncestors.add(id);
const children = childrenByParent.get(id) || [];
const descendantsWidth = children.reduce(
(sum, child) => sum + measureSubtree(child, nextAncestors),
0,
);
const ownWidth = spousesByPersonId.has(id) ? 2 : 1;
const width = Math.max(ownWidth, descendantsWidth || 1);
subtreeWidthById.set(id, width);
return width;
};
const roots = primaryMembers.filter(
(member) => !primaryById.has(String(member.parentId || "")),
);
const rootIds = new Set(roots.map((member) => String(member.id)));
primaryMembers.forEach((member) => {
if (!rootIds.has(String(member.id))) return;
measureSubtree(member);
});
const totalWidth = roots.reduce(
(sum, root) => sum + (subtreeWidthById.get(String(root.id)) || 1),
0,
);
const generations = Array.from(
new Set(primaryMembers.map((member) => Number(member.generation))),
).sort((left, right) => left - right);
const generationIndexByNumber = new Map(
generations.map((generation, index) => [generation, index]),
);
const positionedMembers = [];
const positionedIds = new Set();
const placeSubtree = (member, start, width, ancestors = new Set()) => {
const id = String(member.id);
if (ancestors.has(id)) return;
const nextAncestors = new Set(ancestors);
nextAncestors.add(id);
const spouse = spousesByPersonId.get(id);
const center = start + width / 2;
const memberUnitX = spouse ? center - 0.5 : center;
const y = snapToGrid(
200 + (generationIndexByNumber.get(Number(member.generation)) || 0) * GENERATION_GAP,
);
positionedMembers.push({
...member,
x: snapToGrid(80 + memberUnitX * MEMBER_GAP),
y,
});
positionedIds.add(id);
if (spouse) {
positionedMembers.push({
...spouse,
x: snapToGrid(80 + (memberUnitX + 1) * MEMBER_GAP),
y,
});
positionedIds.add(String(spouse.id));
}
const children = childrenByParent.get(id) || [];
const childrenWidth = children.reduce(
(sum, child) => sum + subtreeWidthById.get(String(child.id)),
0,
);
let childStart = start + (width - childrenWidth) / 2;
children.forEach((child) => {
const childWidth = subtreeWidthById.get(String(child.id));
placeSubtree(child, childStart, childWidth, nextAncestors);
childStart += childWidth;
});
};
let rootStart = 0;
roots.forEach((root) => {
const width = subtreeWidthById.get(String(root.id)) || 1;
placeSubtree(root, rootStart, width);
rootStart += width;
});
return positionedMembers.length === members.value.length
? positionedMembers
: members.value
.filter((member) => !positionedIds.has(String(member.id)))
.reduce((result, member, index) => {
result.push({
...member,
x: snapToGrid(80 + (totalWidth + index + 0.5) * MEMBER_GAP),
y: snapToGrid(
200 +
(generationIndexByNumber.get(Number(member.generation)) || 0) *
GENERATION_GAP,
),
});
return result;
}, positionedMembers);
});
const treeMetrics = computed(() => {
const memberList = layoutMembers.value;
const maxX = Math.max(0, ...memberList.map((member) => member.x));
const maxY = Math.max(0, ...memberList.map((member) => member.y));
const width = Math.max(660, snapToGrid(maxX + NODE_HALF_WIDTH + 50));
const height = Math.max(640, snapToGrid(maxY + 250));
return {
width,
height,
columns: width / GRID_UNIT,
rows: height / GRID_UNIT,
};
});
const treeMetricsStyle = computed(() => ({
width: `${treeMetrics.value.width}rpx`,
height: `${treeMetrics.value.height}rpx`,
gridTemplateColumns: `repeat(${treeMetrics.value.columns}, ${GRID_UNIT}rpx)`,
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${GRID_UNIT}rpx)`,
}));
const generationRailStyle = computed(() => ({
height: `${treeMetrics.value.height}rpx`,
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${GRID_UNIT}rpx)`,
}));
const treeScrollStyle = computed(() => ({
height: `${treeMetrics.value.height}rpx`,
}));
const generationRows = computed(() => {
const groups = new Map();
layoutMembers.value.forEach((member) => {
const group = groups.get(member.generation) || [];
group.push(member);
groups.set(member.generation, group);
});
return Array.from(groups.entries())
.sort(([left], [right]) => left - right)
.map(([generation, group]) => {
const y = Math.min(...group.map((member) => member.y));
return {
generation,
label: `第 ${generation} 世`,
summary: `${group.length} 位成员`,
y,
};
});
});
const generationRangeLabel = computed(() => {
const rows = generationRows.value;
if (!rows.length) return "世系关系";
const first = rows[0].generation;
const last = rows[rows.length - 1].generation;
return first === last ? `第 ${first} 世` : `第 ${first}${last} 世`;
});
const generationBandStyle = (row) => ({
gridRow: `${Math.max(1, Math.round((row.y - 88) / GRID_UNIT) + 1)} / span 12`,
});
const lineageConnectors = computed(() => {
const memberById = new Map(
layoutMembers.value.map((member) => [String(member.id), member]),
);
const spouseByPersonId = new Map();
layoutMembers.value.forEach((member) => {
const partnerId = String(member.spouseOf || "");
if (partnerId && memberById.has(partnerId)) {
spouseByPersonId.set(partnerId, member);
}
});
const childrenByParent = new Map();
layoutMembers.value.forEach((member) => {
const parentId = String(member.parentId || "");
if (member.spouseOf || !parentId || !memberById.has(parentId))
return;
const children = childrenByParent.get(parentId) || [];
children.push(member);
childrenByParent.set(parentId, children);
});
const connectors = [];
childrenByParent.forEach((children, parentId) => {
const parent = memberById.get(parentId);
const spouse = spouseByPersonId.get(parentId);
const parentAnchorX = spouse
? snapToGrid((parent.x + spouse.x) / 2)
: parent.x;
const parentBottom = parent.y + NODE_HALF_HEIGHT;
const parentAnchorY = spouse
? snapToGrid(parentBottom + FAMILY_LINK_OFFSET)
: parentBottom;
const childTop = Math.min(
...children.map((child) => child.y - NODE_HALF_HEIGHT),
);
const branchY = snapToGrid((parentAnchorY + childTop) / 2);
const minX = Math.min(...children.map((child) => child.x));
const maxX = Math.max(...children.map((child) => child.x));
const verticalStyle = (x, top, bottom) => ({
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`,
gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`,
});
const horizontalStyle = (left, right, y) => ({
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
gridRow: `${Math.round(y / GRID_UNIT) + 1} / span 1`,
});
if (spouse) {
connectors.push({
id: `${parentId}-${spouse.id}-family-parent`,
kind: "family",
style: verticalStyle(parent.x, parentBottom, parentAnchorY),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-spouse`,
kind: "family",
style: verticalStyle(
spouse.x,
spouse.y + NODE_HALF_HEIGHT,
parentAnchorY,
),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-bridge`,
kind: "family",
style: horizontalStyle(
Math.min(parent.x, spouse.x),
Math.max(parent.x, spouse.x),
parentAnchorY,
),
});
}
connectors.push({
id: `${parentId}-trunk`,
style: verticalStyle(parentAnchorX, parentAnchorY, branchY),
});
connectors.push({
id: `${parentId}-branch`,
style: {
gridColumn: `${Math.round(minX / GRID_UNIT) + 1} / ${Math.round(maxX / GRID_UNIT) + 2}`,
gridRow: `${Math.round(branchY / GRID_UNIT) + 1} / span 1`,
},
});
children.forEach((child) =>
connectors.push({
id: `${parentId}-${child.id}`,
style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT),
}),
);
});
layoutMembers.value.forEach((member) => {
if (!member.spouseOf) return;
const partner = memberById.get(member.spouseOf);
if (!partner || partner.generation !== member.generation) return;
if (childrenByParent.has(partner.id)) return;
const left = Math.min(member.x, partner.x) + NODE_HALF_WIDTH;
const right = Math.max(member.x, partner.x) - NODE_HALF_WIDTH;
if (right <= left) return;
connectors.push({
id: `${partner.id}-${member.id}-spouse`,
kind: "spouse",
style: {
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
gridRow: `${Math.round(member.y / GRID_UNIT) + 1} / span 1`,
},
});
});
return connectors;
});
const stateCopy = computed(
() =>
({
landscape: {
eyebrow: "世系阅读提示",
title: "上下看世代,左右看同代分支",
copy: "始祖位于上方,后代逐世向下展开;同一世代分支较多时,可左右拖动查看。",
action: "进入世系树",
},
empty: {
eyebrow: "尚未建立世系",
title: "从第一位先祖开始记录",
copy: "录入首代成员后,即可继续补充子女、配偶与后代关系。",
action: "录入首代成员",
},
error: {
eyebrow: "世系暂不可用",
title: "暂时无法读取成员关系",
copy: "请从当前家谱重新进入;已经录入的成员资料不会受到影响。",
action: "重新查看",
},
})[treeState.value] || {},
);
const loadTree = async (query = {}) => {
const generation = ++treeLoadGeneration;
treeRequestController.abort();
if (genealogyContext.isCurrentGenealogyInvalidated()) {
genealogyId.value = "";
members.value = [];
treeState.value = "error";
return;
}
genealogyId.value = String(
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
);
if (!genealogyId.value) {
members.value = [];
treeState.value = "empty";
return;
}
if (
query.state === "loading" ||
["landscape", "empty", "error"].includes(query.state)
) {
treeState.value = query.state;
return;
}
try {
const result = await appApi.getTree(genealogyId.value, {
requestController: treeRequestController,
});
if (!pageActive || generation !== treeLoadGeneration) return;
members.value = result;
genealogyContext.setCurrentGenealogyId(genealogyId.value);
selected.value =
layoutMembers.value.find(
(item) => String(item.id) === String(query.selectedId),
) ||
layoutMembers.value[0] ||
null;
treeState.value = members.value.length ? "tree" : "empty";
if (treeState.value === "tree" && selected.value) {
await nextTick();
recenterSelectedMember();
}
} catch (error) {
if (
!pageActive ||
generation !== treeLoadGeneration ||
isRequestCancelled(error)
) {
return;
}
members.value = [];
selected.value = null;
treeState.value = "error";
}
};
onLoad(loadTree);
onShow(() => {
if (skipNextShowRefresh) {
skipNextShowRefresh = false;
return;
}
if (genealogyId.value) loadTree({ genealogyId: genealogyId.value });
});
onUnload(() => {
pageActive = false;
treeLoadGeneration += 1;
treeRequestController.abort();
if (recenterTimer) clearTimeout(recenterTimer);
});
const handleTreeScroll = (event) => {
if (ignoreTreeScroll || treeState.value !== "tree") return;
const scrollLeft = Number(event?.detail?.scrollLeft || 0);
currentTreeScrollLeft.value = scrollLeft;
treeHasDrifted.value =
Math.abs(scrollLeft - centeredTreeScrollLeft.value) > 80;
};
const getFamilyPartner = (member) => {
if (!member) return null;
const partnerId =
member.spouseOf ||
layoutMembers.value.find(
(item) => String(item.spouseOf || "") === String(member.id),
)?.id;
const partner = layoutMembers.value.find(
(item) => String(item.id) === String(partnerId || ""),
);
return partner && partner.generation === member.generation ? partner : null;
};
const recenterSelectedMember = async () => {
if (!selected.value) return;
const spouse = getFamilyPartner(selected.value);
ignoreTreeScroll = true;
const [scrollRect, nodeRect, spouseRect] = await new Promise((resolve) => {
const query = uni.createSelectorQuery();
query.select(".tree-scroll").boundingClientRect();
query.select(`#tree-member-${selected.value.id}`).boundingClientRect();
if (spouse) {
query.select(`#tree-member-${spouse.id}`).boundingClientRect();
}
query.exec(resolve);
});
if (scrollRect && nodeRect) {
const targetLeft = spouseRect
? Math.min(nodeRect.left, spouseRect.left)
: nodeRect.left;
const targetRight = spouseRect
? Math.max(
nodeRect.left + nodeRect.width,
spouseRect.left + spouseRect.width,
)
: nodeRect.left + nodeRect.width;
const centeredScrollLeft = Math.max(
0,
currentTreeScrollLeft.value +
(targetLeft + targetRight) / 2 -
(scrollRect.left + scrollRect.width / 2),
);
treeScrollLeft.value = centeredScrollLeft;
currentTreeScrollLeft.value = centeredScrollLeft;
centeredTreeScrollLeft.value = centeredScrollLeft;
}
treeHasDrifted.value = false;
if (recenterTimer) clearTimeout(recenterTimer);
recenterTimer = setTimeout(() => {
ignoreTreeScroll = false;
recenterTimer = null;
}, 320);
};
const nodeGridStyle = (member) => ({
gridColumn: `${member.x / 5 + 1}`,
gridRow: `${member.y / 5 + 1}`,
});
const handleStateAction = () => {
if (treeState.value === "empty") {
if (!genealogyId.value) return Promise.resolve(false);
return openPage(
"T04",
{ genealogyId: genealogyId.value, mode: "first" },
"T01",
);
}
if (treeState.value === "error") {
return loadTree({ genealogyId: genealogyId.value });
}
selected.value = layoutMembers.value[0];
treeState.value = "tree";
};
const toMember = () => {
if (!selected.value) return Promise.resolve(false);
closeMemberActionPanel();
return openPage(
"T03",
{ genealogyId: genealogyId.value, personId: String(selected.value.id) },
"T01",
);
};
const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toPedigree = () =>
openPage("T02", { genealogyId: genealogyId.value }, "T01");
const toRank = () =>
selected.value
? openPage(
"T06",
{
genealogyId: genealogyId.value,
personId: String(selected.value.id),
mode: "rank",
},
"T01",
)
: Promise.resolve(false);
const openMemberPanel = (member) => {
selected.value = member;
memberActionPanelVisible.value = true;
};
const closeMemberActionPanel = () => {
memberActionPanelVisible.value = false;
};
const openMemberAction = (action) => {
if (!selected.value) return Promise.resolve(false);
closeMemberActionPanel();
if (!action.routeKey) {
unavailableAction.value = action;
unavailableActionVisible.value = true;
return Promise.resolve(false);
}
const params = {
genealogyId: genealogyId.value,
personId: String(selected.value.id),
};
if (action.routeKey === "T04") {
params.relationType = action.relationType;
} else if (action.routeKey === "T06") {
params.mode = action.mode;
}
return openPage(action.routeKey, params, "T01");
};
</script>
<style scoped lang="scss">
.tree-page {
display: grid;
height: 100vh;
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
background: $paper;
}
.tree-page__header,
.tree-toolbar,
.tree-stage {
z-index: 1;
}
.tree-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 28rpx 12rpx;
}
.tree-toolbar__title text {
display: block;
}
.tree-toolbar__title text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
font-weight: 700;
}
.tree-toolbar__title text:last-child {
margin-top: 5rpx;
color: $ink-muted;
font-size: 22rpx;
}
.tree-toolbar__actions {
display: flex;
gap: 24rpx;
color: $brand-red;
font-size: 23rpx;
}
.tree-stage {
min-height: 0;
overflow-y: auto;
}
.tree-action--pressed {
opacity: 0.72;
}
.tree-stage--lineage {
display: grid;
grid-template-columns: 144rpx minmax(0, 1fr);
align-items: start;
}
.tree-scroll {
width: 100%;
height: 100%;
white-space: nowrap;
}
.generation-rail {
display: grid;
z-index: 4;
width: 144rpx;
height: 100%;
box-sizing: border-box;
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
}
.tree-scroll--lineage {
grid-area: 1 / 2;
min-width: 0;
}
.tree-canvas {
display: grid;
margin: 0 12rpx;
}
.tree-canvas--state {
display: block;
width: calc(100vw - 32rpx);
}
.lineage-pan-cue {
grid-area: 1 / 2;
align-self: start;
justify-self: end;
z-index: 3;
margin: 8rpx 22rpx 0 0;
color: #9a7748;
font-size: 20rpx;
letter-spacing: 1rpx;
}
.generation-band {
display: grid;
justify-self: center;
z-index: 3;
width: 126rpx;
height: 58rpx;
color: #8f6c3f;
font-size: 22rpx;
text-align: center;
}
.generation-band image,
.generation-band__copy {
grid-area: 1 / 1;
}
.generation-band image {
width: 100%;
height: 100%;
}
.generation-band__copy {
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.generation-band__copy text:last-child {
margin-top: 1rpx;
font-size: 18rpx;
}
.lineage-connector {
align-self: stretch;
justify-self: stretch;
z-index: 1;
background: #b78a42;
}
.lineage-connector--spouse {
background: rgba(183, 117, 76, 0.82);
}
.member-node {
position: relative;
align-self: start;
justify-self: start;
z-index: 2;
width: 160rpx;
height: 188rpx;
transform: translate(-50%, -50%);
text-align: center;
}
.member-node__surface {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 9rpx 8rpx 8rpx;
border: 1rpx solid #b78a42;
border-radius: 14rpx;
background: rgba(255, 252, 244, 0.96);
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
}
.member-node__portrait {
display: flex;
width: 62rpx;
height: 62rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1rpx solid #c49a57;
border-radius: 50%;
background: #fffdf7;
overflow: hidden;
}
.member-node__avatar {
width: 48rpx;
height: 48rpx;
}
.member-node__divider {
width: 56rpx;
height: 14rpx;
margin-top: 3rpx;
}
.member-node__copy {
width: 100%;
margin-top: 1rpx;
padding: 0;
}
.node-name,
.node-relation,
.node-years {
display: block;
}
.node-name {
display: -webkit-box;
color: #6b2419;
font-family: "STKaiti", "KaiTi", serif;
overflow: hidden;
font-size: 24rpx;
font-weight: 700;
line-height: 1.1;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
.node-relation {
overflow: hidden;
margin-top: 2rpx;
color: #644b2e;
font-size: 18rpx;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-years {
overflow: hidden;
margin-top: 2rpx;
color: #695b4c;
font-size: 17rpx;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-node--spouse .member-node__surface {
border-color: #c39a61;
background: rgba(255, 250, 241, 0.98);
}
.member-node--selected {
z-index: 3;
}
.member-node--selected .member-node__surface {
border-color: #c55344;
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
}
.member-node--selected .member-node__portrait {
border-color: #c55344;
}
.member-node--selected .node-name {
color: $brand-red;
}
.tree-state-card {
display: grid;
z-index: 2;
width: calc(100% - 48rpx);
min-height: 360rpx;
margin: 150rpx 24rpx 0;
text-align: center;
white-space: normal;
}
.tree-state-card__skin {
width: 100%;
height: 100%;
}
.tree-state-card__skin,
.tree-state-card__content {
grid-area: 1 / 1;
}
.tree-state-card__content {
z-index: 1;
padding: 62rpx 58rpx 42rpx;
}
.tree-state-card__eyebrow {
display: block;
color: $brand-red;
font-size: 22rpx;
letter-spacing: 3rpx;
}
.tree-state-card__title {
display: block;
margin-top: 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx;
font-weight: 700;
}
.tree-state-card__copy {
display: block;
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.tree-state-card__action {
display: grid;
width: 360rpx;
min-height: 70rpx;
margin: 22rpx auto 0;
}
.tree-state-card__action image {
width: 100%;
height: 100%;
}
.tree-state-card__action image,
.tree-state-card__action text {
grid-area: 1 / 1;
}
.tree-state-card__action text {
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #fff9ed;
font-size: 24rpx;
font-weight: 700;
}
.member-action-panel-layer {
position: fixed;
z-index: 80;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
padding: calc(24rpx + env(safe-area-inset-top)) 0 0;
background: rgba(35, 18, 10, 0.62);
}
.member-action-panel {
position: relative;
display: flex;
width: 100%;
max-width: 100%;
height: 900rpx;
min-height: 0;
max-height: calc(
var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) -
env(safe-area-inset-bottom)
);
flex-direction: column;
border-radius: 34rpx 34rpx 0 0;
background: #f8edda;
overflow: hidden;
}
.member-action-panel__paper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-action-panel__head {
position: absolute;
top: 52rpx;
right: 36rpx;
left: 36rpx;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0;
}
.member-action-panel__close-button {
position: absolute;
top: 8rpx;
right: 0;
display: flex;
width: 72rpx;
height: 72rpx;
flex: 0 0 72rpx;
align-items: center;
justify-content: center;
}
.member-action-panel__close {
width: 62rpx;
height: 62rpx;
}
.member-action-panel__content {
z-index: 1;
min-height: 0;
flex: 1;
width: 100%;
box-sizing: border-box;
}
.member-action-panel__scroll-body {
padding: 160rpx 56rpx calc(24rpx + env(safe-area-inset-bottom));
}
.member-action-panel__relationship-divider {
display: block;
width: 100%;
height: 18rpx;
margin-bottom: 20rpx;
}
.member-action-profile {
display: flex;
width: auto;
flex: 0 1 auto;
align-items: center;
gap: 16rpx;
justify-content: center;
padding: 0;
box-sizing: border-box;
}
.member-action-profile__avatar {
width: 92rpx;
height: 92rpx;
aspect-ratio: 1;
flex: 0 0 92rpx;
}
.member-action-profile__copy {
min-width: 0;
text-align: center;
}
.member-action-profile__copy text {
display: block;
}
.member-action-profile__copy text:first-child {
color: #70261f;
font-family: "STKaiti", "KaiTi", serif;
font-size: 38rpx;
font-weight: 700;
}
.member-action-profile__copy text:nth-child(2) {
margin-top: 4rpx;
color: #74533a;
font-size: 22rpx;
}
.member-action-profile__copy text:last-child {
display: none;
}
.member-action-map {
position: relative;
width: 100%;
height: 350rpx;
}
.member-action-map__graph {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-action-map__item {
z-index: 1;
position: absolute;
display: flex;
width: 190rpx;
min-width: 0;
min-height: 60rpx;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 0 10rpx;
box-sizing: border-box;
border: 1rpx solid rgba(177, 126, 64, 0.48);
border-radius: 16rpx;
background: rgba(250, 236, 211, 0.9);
box-shadow: 0 3rpx 7rpx rgba(94, 56, 24, 0.08);
}
.member-action-map__item-frame {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
filter: drop-shadow(0 3rpx 3rpx rgba(99, 57, 21, 0.12));
pointer-events: none;
}
.member-action-map__item > image:not(.member-action-map__item-frame) {
z-index: 1;
width: 36rpx;
height: 36rpx;
}
.member-action-map__item .member-action-map__arrow {
z-index: 1;
width: 18rpx;
height: 18rpx;
}
.member-action-map__item text {
z-index: 1;
color: #672820;
font-size: 22rpx;
font-weight: 700;
white-space: nowrap;
}
.member-action-map__item--top {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.member-action-map__item--left-top {
top: 66rpx;
left: 0;
}
.member-action-map__item--right-top {
top: 66rpx;
right: 0;
}
.member-action-map__item--left-bottom {
bottom: 106rpx;
left: 0;
}
.member-action-map__item--right-bottom {
right: 0;
bottom: 106rpx;
}
.member-action-map__item--bottom {
right: 50%;
bottom: 0;
transform: translateX(50%);
}
.member-action-grid {
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 34rpx;
padding-top: 30rpx;
border-top: 2rpx solid rgba(169, 117, 55, 0.68);
}
.member-action-grid__item {
display: flex;
min-height: 96rpx;
align-items: center;
gap: 14rpx;
padding: 0 20rpx;
box-sizing: border-box;
border: 2rpx solid rgba(160, 102, 47, 0.62);
border-radius: 10rpx;
background: rgba(249, 234, 204, 0.92);
}
.member-action-grid__item image {
width: 38rpx;
height: 38rpx;
}
.member-action-grid__item .member-action-grid__arrow {
width: 22rpx;
height: 22rpx;
margin-left: auto;
}
.member-action-grid__item text {
color: #672820;
font-size: 25rpx;
font-weight: 700;
}
.member-action-panel__hint {
display: none;
}
@media (max-width: 360px) {
.tree-toolbar {
padding-right: 20rpx;
padding-left: 20rpx;
}
.tree-toolbar__actions {
gap: 14rpx;
}
.member-action-profile {
gap: 14rpx;
padding-right: 14rpx;
padding-left: 14rpx;
}
}
</style>