1494 lines
41 KiB
Vue
1494 lines
41 KiB
Vue
<!-- 页面编号: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)"
|
||
>
|
||
<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': 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">
|
||
<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/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"
|
||
>
|
||
<view class="member-action-profile__avatar">
|
||
<AppAvatar :sex="selected.sex" />
|
||
</view>
|
||
<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 AppAvatar from "@/components/AppAvatar.vue";
|
||
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 = 112;
|
||
const FAMILY_LINK_OFFSET = 18;
|
||
const MEMBER_GAP = 178;
|
||
const GENERATION_GAP = 296;
|
||
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 - NODE_HALF_HEIGHT) / GRID_UNIT) + 1)} / span 21`,
|
||
});
|
||
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: 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;
|
||
gap: 24rpx;
|
||
color: $brand-red;
|
||
font-size: clamp(14px, 23rpx, 17px);
|
||
}
|
||
.tree-stage {
|
||
min-height: 0;
|
||
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: 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: clamp(15px, 24rpx, 18px);
|
||
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 {
|
||
display: block;
|
||
width: 92rpx;
|
||
height: 92rpx;
|
||
aspect-ratio: 1;
|
||
flex: 0 0 92rpx;
|
||
box-sizing: border-box;
|
||
border: 3rpx solid #d0a65d;
|
||
border-radius: 50%;
|
||
background: #fff8e8;
|
||
box-shadow: 0 3rpx 8rpx rgba(105, 65, 29, 0.18);
|
||
overflow: hidden;
|
||
}
|
||
.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: clamp(20px, 38rpx, 26px);
|
||
font-weight: 700;
|
||
}
|
||
.member-action-profile__copy text:nth-child(2) {
|
||
margin-top: 4rpx;
|
||
color: #74533a;
|
||
font-size: clamp(14px, 22rpx, 17px);
|
||
}
|
||
.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: clamp(14px, 22rpx, 17px);
|
||
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: clamp(15px, 25rpx, 18px);
|
||
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>
|