Files
jiapuapp/pages/tree/t01-tree-overview.vue
T
2026-07-23 08:24:07 +08:00

766 lines
21 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="toDirectory" />
</view>
<view v-if="treeState !== 'loading'" class="tree-toolbar">
<view class="tree-toolbar__title">
<text>汤氏家谱</text>
<text>主支 · 1214 </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="toRelationship">关系维护</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"
: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 }"
:style="nodeGridStyle(member)"
@click="selected = member"
>
<image
class="member-node__skin"
:src="
selected?.id === member.id
? '/static/assets/modules/tree/transparent/t01-member-node-selected.png'
: '/static/assets/modules/tree/transparent/t01-member-node-standard.png'
"
mode="scaleToFill"
/>
<view class="member-node__copy">
<text class="node-name">{{ member.name }}</text>
<text class="node-relation"
>{{ member.relation }} · {{ member.branch }}</text
>
<text class="node-years">{{ member.years }}</text>
</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="treeState === 'tree' && selected" class="member-sheet">
<image
class="member-sheet__skin"
src="/static/assets/modules/tree/transparent/t01-member-drawer.png"
mode="scaleToFill"
/>
<view class="member-sheet__copy">
<text class="sheet-name">{{ selected.name }}</text>
<text class="sheet-meta"
> {{ selected.generation }} · {{ selected.relation }} ·
{{ selected.branch }}</text
>
</view>
<view class="sheet-actions">
<AppButton
class="sheet-action"
type="secondary"
label="查看资料"
@click="toMember"
/>
<AppButton
class="sheet-action"
label="添加亲属"
@click="toAddRelative"
/>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { getGenealogyFixtureAccess, listTreeMemberFixtures } from "@/data/mock.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 treeScrollLeft = ref(90);
const currentTreeScrollLeft = ref(90);
const centeredTreeScrollLeft = ref(90);
const treeHasDrifted = ref(false);
let ignoreTreeScroll = false;
let recenterTimer = null;
const GRID_UNIT = 5;
const NODE_HALF_HEIGHT = 47;
const MEMBER_GAP = 250;
const GENERATION_GAP = 220;
const generationMeta = {
12: { label: "第十二世", summary: "始祖" },
13: { label: "第十三世", summary: "两房" },
14: { label: "第十四世", summary: "三支" },
};
const members = ref([]);
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
const layoutMembers = computed(() => {
const generations = Array.from(
new Set(members.value.map((member) => Number(member.generation))),
).sort((left, right) => left - right);
const groups = new Map(
generations.map((generation) => [
generation,
members.value
.filter((member) => Number(member.generation) === generation)
.sort(
(left, right) =>
Number(left.parentId || 0) - Number(right.parentId || 0) ||
Number(left.id) - Number(right.id),
),
]),
);
const maxCount = Math.max(1, ...Array.from(groups.values()).map((group) => group.length));
const canvasWidth = Math.max(720, maxCount * MEMBER_GAP + 100);
return generations.flatMap((generation, generationIndex) => {
const group = groups.get(generation) || [];
const occupiedWidth = Math.max(0, (group.length - 1) * MEMBER_GAP);
const startX = (canvasWidth - occupiedWidth) / 2;
return group.map((member, memberIndex) => ({
...member,
x: snapToGrid(startX + memberIndex * MEMBER_GAP),
y: snapToGrid(135 + generationIndex * GENERATION_GAP),
}));
});
});
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(720, snapToGrid(maxX + 210));
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: generationMeta[generation]?.label || `第 ${generation} 世`,
summary: generationMeta[generation]?.summary || `${group.length} 位成员`,
y,
};
});
});
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) => [member.id, member]),
);
const childrenByParent = new Map();
layoutMembers.value.forEach((member) => {
if (!member.parentId || !memberById.has(member.parentId)) return;
const children = childrenByParent.get(member.parentId) || [];
children.push(member);
childrenByParent.set(member.parentId, children);
});
const connectors = [];
childrenByParent.forEach((children, parentId) => {
const parent = memberById.get(parentId);
const childTop = Math.min(...children.map((child) => child.y - NODE_HALF_HEIGHT));
const parentBottom = parent.y + NODE_HALF_HEIGHT;
const branchY = snapToGrid((parentBottom + 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}`,
});
connectors.push({
id: `${parentId}-trunk`,
style: verticalStyle(parent.x, parentBottom, 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),
}));
});
return connectors;
});
const stateCopy = computed(
() =>
({
landscape: {
eyebrow: "世系阅读提示",
title: "上下看世代,左右看同代分支",
copy: "始祖位于上方,后代逐世向下展开;同一世代分支较多时,可左右拖动查看。",
action: "进入世系树",
},
empty: {
eyebrow: "尚未建立世系",
title: "从第一位先祖开始记录",
copy: "录入首代成员后,即可继续补充子女、配偶与后代关系。",
action: "录入首代成员",
},
error: {
eyebrow: "世系暂不可用",
title: "暂时无法读取成员关系",
copy: "请从当前家谱重新进入;已经录入的成员资料不会受到影响。",
action: "重新查看",
},
})[treeState.value] || {},
);
onLoad((query) => {
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;
}
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
genealogyContext.invalidateCurrentGenealogyId();
genealogyId.value = "";
members.value = [];
treeState.value = "error";
return;
}
members.value = listTreeMemberFixtures(genealogyId.value);
if (
query.state === "loading" ||
["landscape", "empty", "error"].includes(query.state)
) {
treeState.value = query.state;
return;
}
genealogyContext.setCurrentGenealogyId(genealogyId.value);
selected.value =
layoutMembers.value.find(
(item) => String(item.id) === String(query.selectedId),
) || layoutMembers.value[0];
treeState.value = members.value.length ? "tree" : "empty";
});
onUnload(() => {
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 recenterSelectedMember = async () => {
if (!selected.value) return;
ignoreTreeScroll = true;
const [scrollRect, nodeRect] = await new Promise((resolve) => {
const query = uni.createSelectorQuery();
query.select(".tree-scroll").boundingClientRect();
query
.select(`#tree-member-${selected.value.id}`)
.boundingClientRect();
query.exec(resolve);
});
if (scrollRect && nodeRect) {
const centeredScrollLeft = Math.max(
0,
currentTreeScrollLeft.value +
nodeRect.left + nodeRect.width / 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",
);
}
selected.value = layoutMembers.value[0];
treeState.value = "tree";
};
const toMember = () =>
selected.value
? openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toRelationship = () =>
selected.value
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
const toAddRelative = () =>
selected.value
? openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
: Promise.resolve(false);
</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: 190rpx minmax(0, 1fr);
align-items: start;
}
.tree-scroll {
width: 100%;
height: 100%;
white-space: nowrap;
}
.generation-rail {
display: grid;
z-index: 4;
width: 190rpx;
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 16rpx;
}
.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: 166rpx;
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: 19rpx;
}
.lineage-connector {
align-self: stretch;
justify-self: stretch;
z-index: 1;
background: #b78a42;
}
.member-node {
display: grid;
align-self: start;
justify-self: start;
z-index: 2;
width: 224rpx;
height: 86rpx;
transform: translate(-50%, -50%);
text-align: center;
}
.member-node__skin {
width: 100%;
height: 100%;
}
.member-node__skin,
.member-node__copy {
grid-area: 1 / 1;
}
.member-node__copy {
z-index: 1;
}
.node-name,
.node-relation,
.node-years {
display: block;
}
.node-name {
padding-top: 5rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 25rpx;
font-weight: 700;
}
.node-relation {
margin-top: 0;
color: $brand-red;
font-size: 21rpx;
}
.node-years {
margin-top: 0;
color: $ink-muted;
font-size: 20rpx;
}
.member-node--selected {
width: 236rpx;
height: 94rpx;
}
.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-sheet {
position: fixed;
right: 18rpx;
bottom: calc(14rpx + env(safe-area-inset-bottom));
left: 18rpx;
display: flex;
min-height: 240rpx;
flex-direction: column;
padding: 38rpx 44rpx 24rpx;
box-sizing: border-box;
}
.member-sheet__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-sheet__copy {
z-index: 1;
}
.sheet-name {
display: block;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
}
.sheet-meta {
display: block;
margin-top: 8rpx;
color: $ink-muted;
font-size: 22rpx;
}
.sheet-actions {
z-index: 1;
display: flex;
justify-content: center;
gap: 16rpx;
margin-top: auto;
}
.sheet-action {
width: 250rpx;
min-height: 88rpx;
}
@media (max-width: 360px) {
.tree-toolbar {
padding-right: 20rpx;
padding-left: 20rpx;
}
.tree-toolbar__actions {
gap: 14rpx;
}
.member-sheet {
min-height: 240rpx;
padding-top: 32rpx;
padding-right: 36rpx;
padding-left: 36rpx;
padding-bottom: 20rpx;
}
}
</style>