849 lines
23 KiB
Vue
849 lines
23 KiB
Vue
<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 && selectedMember"
|
|
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)"
|
|
>
|
|
<view class="generation-band__line" aria-hidden="true" />
|
|
<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': selectedMember?.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">
|
|
<AppAvatar :sex="member.sex" />
|
|
</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.treeYears || 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/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/scroll-primary.png"
|
|
mode="aspectFit"
|
|
/>
|
|
<text>{{ stateCopy.action }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
|
|
<MemberActionPanel
|
|
:visible="memberActionPanelVisible"
|
|
:member="selectedMember"
|
|
:relation-actions="relationActions"
|
|
:management-actions="managementActions"
|
|
@close="closeMemberActionPanel"
|
|
@view-profile="toMember"
|
|
@select-action="openMemberAction"
|
|
/>
|
|
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, ref } from "vue";
|
|
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
|
import AppAvatar from "@/components/AppAvatar.vue";
|
|
import AppLoading from "@/components/AppLoading.vue";
|
|
import MemberActionPanel from "@/components/tree/MemberActionPanel.vue";
|
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
|
import PageHeader from "@/components/PageHeader.vue";
|
|
import {
|
|
createRequestController,
|
|
isRequestCancelled
|
|
} from "@/services/api/request-controller.js";
|
|
import { lineageApi } from "@/services/api/lineage-service.js";
|
|
import { genealogyContext } from "@/utils/genealogy/context.js";
|
|
import { openPage } from "@/utils/navigation/gateway.js";
|
|
import {
|
|
createTreeLayout,
|
|
treeNodeGridStyle,
|
|
} from "@/utils/tree/layout.js";
|
|
import { memberRelationTypes } from "@/utils/tree/member-form.js";
|
|
|
|
const genealogyId = ref("");
|
|
const treeState = ref("loading");
|
|
const selectedMember = ref(null);
|
|
const memberActionPanelVisible = ref(false);
|
|
const treeScrollLeft = ref(90);
|
|
const currentTreeScrollLeft = ref(90);
|
|
const centeredTreeScrollLeft = ref(90);
|
|
const treeHasDrifted = ref(false);
|
|
let ignoreTreeScroll = false;
|
|
let recenterTimer = null;
|
|
const treeOverviewRequestController = createRequestController();
|
|
// abort 负责停止当前任务,generation 负责拦截已经进入回调队列的旧响应;
|
|
// 两者同时保留,避免快速切换家谱时旧世系覆盖新世系。
|
|
let treeLoadGeneration = 0;
|
|
let pageActive = true;
|
|
let skipNextShowRefresh = true;
|
|
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: memberRelationTypes.FATHER,
|
|
},
|
|
{
|
|
key: "ADD_MOTHER",
|
|
label: "添加母亲",
|
|
shortLabel: "母亲",
|
|
group: "RELATION",
|
|
slot: "left-top",
|
|
routeKey: "T04",
|
|
relationType: memberRelationTypes.MOTHER,
|
|
},
|
|
{
|
|
key: "ADD_SPOUSE",
|
|
label: "添加配偶",
|
|
shortLabel: "配偶",
|
|
group: "RELATION",
|
|
slot: "right-top",
|
|
routeKey: "T04",
|
|
relationType: memberRelationTypes.SPOUSE,
|
|
},
|
|
{
|
|
key: "ADD_SIBLING",
|
|
label: "添加兄弟姐妹",
|
|
shortLabel: "兄弟姐妹",
|
|
group: "RELATION",
|
|
slot: "left-bottom",
|
|
routeKey: "T04",
|
|
relationType: memberRelationTypes.SIBLING,
|
|
},
|
|
{
|
|
key: "ADJUST_RANK",
|
|
label: "调整排行",
|
|
group: "MANAGEMENT",
|
|
routeKey: "T06",
|
|
mode: "rank",
|
|
},
|
|
{
|
|
key: "ADD_SON",
|
|
label: "添加儿子",
|
|
shortLabel: "儿子",
|
|
group: "RELATION",
|
|
slot: "bottom",
|
|
routeKey: "T04",
|
|
relationType: memberRelationTypes.SON,
|
|
},
|
|
{
|
|
key: "ADD_DAUGHTER",
|
|
label: "添加女儿",
|
|
shortLabel: "女儿",
|
|
group: "RELATION",
|
|
slot: "right-bottom",
|
|
routeKey: "T04",
|
|
relationType: memberRelationTypes.DAUGHTER,
|
|
},
|
|
{
|
|
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 nodeRelationText = (member) => {
|
|
const branch = String(member.branch || "").replace(/字辈$/, "");
|
|
return [member.relation, branch].filter(Boolean).join(" · ");
|
|
};
|
|
const treeLayout = computed(() => createTreeLayout(members.value));
|
|
const layoutMembers = computed(() => treeLayout.value.members);
|
|
const treeMetrics = computed(() => treeLayout.value.metrics);
|
|
const treeMetricsStyle = computed(() => ({
|
|
width: `${treeMetrics.value.width}rpx`,
|
|
height: `${treeMetrics.value.height}rpx`,
|
|
gridTemplateColumns: `repeat(${treeMetrics.value.columns}, ${treeMetrics.value.gridUnit}rpx)`,
|
|
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${treeMetrics.value.gridUnit}rpx)`,
|
|
}));
|
|
const generationRailStyle = computed(() => ({
|
|
height: `${treeMetrics.value.height}rpx`,
|
|
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${treeMetrics.value.gridUnit}rpx)`,
|
|
}));
|
|
const treeScrollStyle = computed(() => ({
|
|
height: `${treeMetrics.value.height}rpx`,
|
|
}));
|
|
const generationRows = computed(() => treeLayout.value.generationRows);
|
|
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 = (generationRow) => generationRow.bandStyle;
|
|
const lineageConnectors = computed(() => treeLayout.value.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;
|
|
treeOverviewRequestController.abort();
|
|
if (genealogyContext.isCurrentGenealogyInvalidated()) {
|
|
genealogyId.value = "";
|
|
members.value = [];
|
|
treeState.value = "error";
|
|
return;
|
|
}
|
|
genealogyId.value = String(
|
|
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
|
|
);
|
|
if (!/^[1-9]\d*$/.test(genealogyId.value)) {
|
|
members.value = [];
|
|
treeState.value = "empty";
|
|
return;
|
|
}
|
|
try {
|
|
const loadedMembers = await lineageApi.getTree(genealogyId.value, {
|
|
requestController: treeOverviewRequestController,
|
|
});
|
|
if (!pageActive || generation !== treeLoadGeneration) return;
|
|
members.value = loadedMembers;
|
|
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
|
selectedMember.value =
|
|
layoutMembers.value.find(
|
|
(member) => String(member.id) === String(query.selectedId),
|
|
) ||
|
|
layoutMembers.value[0] ||
|
|
null;
|
|
treeState.value = members.value.length ? "tree" : "empty";
|
|
if (treeState.value === "tree" && selectedMember.value) {
|
|
await nextTick();
|
|
recenterSelectedMember();
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
!pageActive ||
|
|
generation !== treeLoadGeneration ||
|
|
isRequestCancelled(error)
|
|
) {
|
|
return;
|
|
}
|
|
members.value = [];
|
|
selectedMember.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;
|
|
treeOverviewRequestController.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(
|
|
(candidate) => String(candidate.spouseOf || "") === String(member.id),
|
|
)?.id;
|
|
const partner = layoutMembers.value.find(
|
|
(candidate) => String(candidate.id) === String(partnerId || ""),
|
|
);
|
|
return partner && partner.generation === member.generation ? partner : null;
|
|
};
|
|
|
|
const recenterSelectedMember = async () => {
|
|
if (!selectedMember.value) return;
|
|
const spouse = getFamilyPartner(selectedMember.value);
|
|
ignoreTreeScroll = true;
|
|
const [scrollRect, nodeRect, spouseRect] = await new Promise((resolve) => {
|
|
const query = uni.createSelectorQuery();
|
|
query.select(".tree-scroll").boundingClientRect();
|
|
query.select(`#tree-member-${selectedMember.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 = treeNodeGridStyle;
|
|
const handleStateAction = () => {
|
|
if (treeState.value === "empty") {
|
|
if (!/^[1-9]\d*$/.test(genealogyId.value)) return Promise.resolve(false);
|
|
return openPage(
|
|
"T04",
|
|
{ genealogyId: genealogyId.value, mode: "first" },
|
|
"T01",
|
|
);
|
|
}
|
|
if (treeState.value === "error") {
|
|
return loadTree({ genealogyId: genealogyId.value });
|
|
}
|
|
selectedMember.value = layoutMembers.value[0];
|
|
treeState.value = "tree";
|
|
};
|
|
const toMember = () => {
|
|
if (!selectedMember.value) return Promise.resolve(false);
|
|
closeMemberActionPanel();
|
|
return openPage(
|
|
"T03",
|
|
{ genealogyId: genealogyId.value, personId: String(selectedMember.value.id) },
|
|
"T01",
|
|
);
|
|
};
|
|
const toDirectory = () =>
|
|
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
|
const toPedigree = () =>
|
|
openPage("T02", { genealogyId: genealogyId.value }, "T01");
|
|
const toRank = () =>
|
|
selectedMember.value
|
|
? openPage(
|
|
"T06",
|
|
{
|
|
genealogyId: genealogyId.value,
|
|
personId: String(selectedMember.value.id),
|
|
mode: "rank",
|
|
},
|
|
"T01",
|
|
)
|
|
: Promise.resolve(false);
|
|
const openMemberPanel = (member) => {
|
|
selectedMember.value = member;
|
|
memberActionPanelVisible.value = true;
|
|
};
|
|
const closeMemberActionPanel = () => {
|
|
memberActionPanelVisible.value = false;
|
|
};
|
|
const openMemberAction = (action) => {
|
|
if (!selectedMember.value || !action?.routeKey) return Promise.resolve(false);
|
|
closeMemberActionPanel();
|
|
const params = {
|
|
genealogyId: genealogyId.value,
|
|
personId: String(selectedMember.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: clamp(16px, 28rpx, 20px);
|
|
font-weight: 700;
|
|
}
|
|
.tree-toolbar__title text:last-child {
|
|
margin-top: 5rpx;
|
|
color: $ink-muted;
|
|
font-size: clamp(14px, 22rpx, 17px);
|
|
}
|
|
.tree-toolbar__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 24rpx;
|
|
color: $brand-red;
|
|
font-size: clamp(14px, 23rpx, 17px);
|
|
}
|
|
.tree-toolbar__actions > text {
|
|
display: flex;
|
|
min-height: var(--app-touch-min);
|
|
align-items: center;
|
|
}
|
|
.tree-stage {
|
|
min-height: 0;
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
box-sizing: border-box;
|
|
overflow-y: auto;
|
|
}
|
|
.tree-action--pressed {
|
|
opacity: 0.72;
|
|
}
|
|
.tree-stage--lineage {
|
|
display: grid;
|
|
grid-template-columns: 72rpx minmax(0, 1fr);
|
|
align-items: start;
|
|
}
|
|
.tree-scroll {
|
|
width: 100%;
|
|
height: 100%;
|
|
white-space: nowrap;
|
|
}
|
|
.generation-rail {
|
|
display: grid;
|
|
z-index: 4;
|
|
width: 72rpx;
|
|
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: clamp(13px, 20rpx, 16px);
|
|
letter-spacing: 1rpx;
|
|
}
|
|
.generation-band {
|
|
position: relative;
|
|
display: grid;
|
|
justify-self: center;
|
|
z-index: 3;
|
|
width: 62rpx;
|
|
height: 104rpx;
|
|
color: #8f6c3f;
|
|
font-size: clamp(12px, 18rpx, 14px);
|
|
text-align: center;
|
|
}
|
|
.generation-band__line,
|
|
.generation-band__copy {
|
|
grid-area: 1 / 1;
|
|
}
|
|
.generation-band__line {
|
|
align-self: stretch;
|
|
justify-self: center;
|
|
width: 0;
|
|
margin: 3rpx 0;
|
|
border-left: 1rpx solid rgba(183, 138, 66, 0.7);
|
|
}
|
|
.generation-band__copy {
|
|
z-index: 1;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0;
|
|
height: 100%;
|
|
padding: 0 4rpx;
|
|
box-sizing: border-box;
|
|
}
|
|
.generation-band__copy text {
|
|
writing-mode: vertical-rl;
|
|
text-orientation: upright;
|
|
white-space: nowrap;
|
|
}
|
|
.generation-band__copy text:last-child {
|
|
font-size: clamp(11px, 14rpx, 13px);
|
|
}
|
|
.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: 224rpx;
|
|
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__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: clamp(15px, 24rpx, 18px);
|
|
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: clamp(12px, 18rpx, 14px);
|
|
line-height: 1.2;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.node-years {
|
|
overflow: hidden;
|
|
margin-top: 2rpx;
|
|
color: #695b4c;
|
|
font-size: clamp(12px, 17rpx, 14px);
|
|
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: clamp(14px, 22rpx, 17px);
|
|
letter-spacing: 3rpx;
|
|
}
|
|
.tree-state-card__title {
|
|
display: block;
|
|
margin-top: 12rpx;
|
|
color: $ink;
|
|
font-family: "STKaiti", "KaiTi", serif;
|
|
font-size: clamp(19px, 34rpx, 24px);
|
|
font-weight: 700;
|
|
}
|
|
.tree-state-card__copy {
|
|
display: block;
|
|
margin-top: 16rpx;
|
|
color: $ink-muted;
|
|
font-size: clamp(15px, 24rpx, 18px);
|
|
line-height: 1.65;
|
|
}
|
|
.tree-state-card__action {
|
|
display: grid;
|
|
width: 360rpx;
|
|
min-height: var(--app-touch-min);
|
|
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: clamp(15px, 24rpx, 18px);
|
|
font-weight: 700;
|
|
}
|
|
|
|
@media (max-width: 360px) {
|
|
.tree-toolbar {
|
|
padding-right: 20rpx;
|
|
padding-left: 20rpx;
|
|
}
|
|
.tree-toolbar__actions {
|
|
gap: 14rpx;
|
|
}
|
|
}
|
|
</style>
|