Files
jiapuapp/pages/tree/t02-pedigree-overview.vue
T
2026-07-29 18:16:08 +08:00

587 lines
17 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-02用途世系谱表阅读同世成员横向滑页 -->
<template>
<view class="pedigree-page">
<ModulePageBackground module="tree" />
<view class="pedigree-page__header">
<PageHeader title="世系谱" action="树状图" @action="toTree" />
</view>
<view class="pedigree-stage">
<AppLoading
v-if="treeState === 'loading'"
text="正在整理世系谱"
description="请稍候,正在准备家谱成员关系。"
/>
<template v-else-if="treeState === 'tree'">
<view class="pedigree-layout">
<swiper
class="pedigree-swiper"
:current="pedigreePageIndex"
:duration="260"
:disable-touch="false"
@change="handlePedigreePageChange"
>
<swiper-item
v-for="(page, pageIndex) in pedigreePages"
:key="`generation-${page.generation}-page-${pageIndex}`"
>
<scroll-view
class="pedigree-scroll"
scroll-y
:show-scrollbar="false"
:style="pedigreeScrollStyle"
>
<view class="pedigree-sheet" :style="pedigreeSheetStyle">
<view class="pedigree-column pedigree-column--legend">
<view class="pedigree-column__relation"><text>身份</text></view>
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
<view class="pedigree-column__detail"><text>{{ page.summary }}</text></view>
</view>
<view
v-for="(member, memberIndex) in page.members"
:key="member?.id || `empty-${pageIndex}-${memberIndex}`"
class="member-node"
:class="{
'member-node--blank': !member,
'member-node--selected': selectedId === String(member?.id || ''),
'member-node--spouse': Boolean(member?.spouseOf),
}"
>
<view v-if="member" class="member-node__surface">
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
<view
class="member-node__name"
role="button"
:aria-label="`查看${member.name}的资料`"
@click.stop="openMemberProfile(member)"
><text>{{ member.name }}</text></view>
<view
class="member-node__copy"
role="button"
:aria-label="`查看${member.name}的生平详情`"
@click.stop="openMemberDetail(member)"
>
<text class="node-relation">{{ member.branch }}</text>
<text class="node-years">{{ memberDetail(member) }}</text>
</view>
</view>
<view v-else class="member-node__surface">
<view class="member-node__relation"></view>
<view class="member-node__name"></view>
<view class="member-node__copy"></view>
</view>
</view>
</view>
</scroll-view>
</swiper-item>
</swiper>
<view class="generation-rail" :style="pedigreeSheetStyle">
<view class="generation-band">
<image
class="generation-band__arrow generation-band__arrow--previous"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
<view class="generation-band__copy">
<text>{{ activePage?.label || '未定世代' }}</text>
<text>{{ activePage?.memberCount || 0 }} 位成员</text>
</view>
<image
class="generation-band__arrow"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
</view>
</view>
</template>
<view v-else class="pedigree-state-card">
<image
class="pedigree-state-card__skin"
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
mode="scaleToFill"
/>
<view class="pedigree-state-card__content">
<text class="pedigree-state-card__eyebrow">{{ stateCopy.eyebrow }}</text>
<text class="pedigree-state-card__title">{{ stateCopy.title }}</text>
<text class="pedigree-state-card__copy">{{ stateCopy.copy }}</text>
<view class="pedigree-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>
<AppDialog
:visible="detailVisible"
eyebrow="人物生平"
:title="detailMember?.name || '人物详情'"
:message="detailMessage"
confirm-text="关闭"
@confirm="closeMemberDetail"
@cancel="closeMemberDetail"
/>
</view>
</template>
<script setup>
import { computed, 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 PEDIGREE_PAGE_SIZE = 5;
const genealogyId = ref("");
const treeState = ref("loading");
const treeLoadError = ref("");
const members = ref([]);
const selectedId = ref("");
const pedigreePageIndex = ref(0);
const detailVisible = ref(false);
const detailMember = ref(null);
const detailMessage = ref("");
const pedigreeRequestController = createRequestController();
const detailRequestController = createRequestController();
let loadGeneration = 0;
let detailLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
const compareMembers = (left, right) =>
Number(left.generation) - Number(right.generation) ||
String(left.id).localeCompare(String(right.id));
const generationRows = computed(() => {
const groups = new Map();
members.value.forEach((member) => {
const generation = Number(member.generation) || 0;
const group = groups.get(generation) || [];
group.push(member);
groups.set(generation, group);
});
return Array.from(groups.entries())
.sort(([left], [right]) => left - right)
.map(([generation, group]) => ({
generation,
label: generation ? `第 ${generation} 世` : "未定世代",
members: group.sort(compareMembers),
}));
});
const pedigreePages = computed(() => {
const pages = [];
generationRows.value.forEach((row) => {
const branch = row.members.find((member) => member.branch)?.branch;
for (let index = 0; index < row.members.length; index += PEDIGREE_PAGE_SIZE) {
const pageMembers = row.members.slice(index, index + PEDIGREE_PAGE_SIZE);
while (pageMembers.length < PEDIGREE_PAGE_SIZE) pageMembers.push(null);
pages.push({
generation: row.generation,
label: row.label,
memberCount: row.members.length,
summary: [branch, `${row.members.length} 位成员`]
.filter(Boolean)
.join(" · "),
members: pageMembers,
});
}
});
return pages;
});
const activePage = computed(
() => pedigreePages.value[pedigreePageIndex.value] || null,
);
const detailLength = computed(() =>
Math.max(
0,
...members.value.map((member) =>
Array.from(`${member.branch || ""}${memberDetail(member)}`).length,
),
),
);
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
const pedigreeSheetStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
const pedigreeScrollStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
const stateCopy = computed(
() =>
({
landscape: {
eyebrow: "世系谱阅读提示",
title: "左右滑动查看成员",
copy: "同一世代每页展示五位成员,继续向左滑动即可查看下一页。",
action: "返回树状图",
},
empty: {
eyebrow: "尚未建立世系",
title: "从第一位先祖开始记录",
copy: "录入首代成员后,即可在世系树与世系谱中查看关系。",
action: "添加首位成员",
},
error: {
eyebrow: "世系谱读取失败",
title: "暂时无法展示家谱成员",
copy: treeLoadError.value || "请稍后重试。",
action: "重新查看",
},
})[treeState.value] || {},
);
const handlePedigreePageChange = (event) => {
pedigreePageIndex.value = Number(event?.detail?.current || 0);
};
const memberRelation = (member) =>
member.spouseOf ? "配偶" : member.relation || "家谱成员";
const memberDetail = (member) =>
[member.years, member.sex, member.personStatus]
.filter((item) => item && item !== "生卒待补")
.join(" · ") || "生卒待补";
const openMemberProfile = (member) =>
openPage(
"T03",
{ genealogyId: genealogyId.value, personId: String(member.id) },
"T02",
);
const closeMemberDetail = () => {
detailVisible.value = false;
detailRequestController.abort();
};
const openMemberDetail = async (member) => {
if (!member) return;
const generation = ++detailLoadGeneration;
detailRequestController.abort();
detailMember.value = member;
detailMessage.value = "正在读取人物生平…";
detailVisible.value = true;
try {
const detail = await appApi.getPerson(genealogyId.value, member.id, {
requestController: detailRequestController,
});
if (generation !== detailLoadGeneration || !detailVisible.value) return;
detailMember.value = detail;
detailMessage.value =
[detail.biography, detail.remark].filter(Boolean).join("\n\n") ||
"暂未填写生平说明。";
} catch (error) {
if (isRequestCancelled(error) || generation !== detailLoadGeneration) return;
detailMessage.value = error?.message || "人物生平读取失败,请稍后重试。";
}
};
const toTree = () => {
const params = { genealogyId: genealogyId.value };
if (selectedId.value) params.selectedId = selectedId.value;
return openPage("T01", params, "T02");
};
const handleStateAction = () => {
if (treeState.value === "landscape") return toTree();
if (treeState.value === "empty") {
return openPage(
"T04",
{ genealogyId: genealogyId.value, mode: "first" },
"T02",
);
}
return loadPedigree({ genealogyId: genealogyId.value });
};
const loadPedigree = async (query = {}) => {
const generation = ++loadGeneration;
pedigreeRequestController.abort();
treeLoadError.value = "";
genealogyId.value = String(
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
);
selectedId.value = String(query.selectedId || "");
if (!genealogyId.value) {
members.value = [];
treeState.value = "empty";
return;
}
if (["landscape", "empty", "error"].includes(query.state)) {
treeState.value = query.state;
return;
}
treeState.value = "loading";
try {
const result = await appApi.getTree(genealogyId.value, {
requestController: pedigreeRequestController,
});
if (!pageActive || generation !== loadGeneration) return;
members.value = result;
genealogyContext.setCurrentGenealogyId(genealogyId.value);
const selectedPage = pedigreePages.value.findIndex((page) =>
page.members.some((member) => String(member?.id) === selectedId.value),
);
pedigreePageIndex.value = selectedPage < 0 ? 0 : selectedPage;
treeState.value = members.value.length ? "tree" : "empty";
} catch (error) {
if (!pageActive || generation !== loadGeneration || isRequestCancelled(error)) {
return;
}
members.value = [];
treeLoadError.value = error?.message || "世系谱读取失败,请稍后重试。";
treeState.value = "error";
}
};
onLoad(loadPedigree);
onShow(() => {
if (skipNextShowRefresh) {
skipNextShowRefresh = false;
return;
}
if (genealogyId.value) loadPedigree({ genealogyId: genealogyId.value });
});
onUnload(() => {
pageActive = false;
loadGeneration += 1;
detailLoadGeneration += 1;
pedigreeRequestController.abort();
detailRequestController.abort();
});
</script>
<style scoped lang="scss">
.pedigree-page {
display: grid;
min-height: 100vh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
background: $paper;
}
.pedigree-page__header,
.pedigree-stage {
z-index: 1;
}
.pedigree-stage {
min-height: 0;
}
.pedigree-layout {
display: grid;
height: 100%;
grid-template-columns: minmax(0, 1fr) 92rpx;
background: #fbfbfb;
}
.pedigree-swiper {
width: 100%;
height: 100%;
}
.pedigree-scroll {
width: 100%;
}
.pedigree-sheet {
display: grid;
width: 100%;
min-height: 100%;
grid-template-columns: repeat(6, minmax(0, 1fr));
align-items: stretch;
border-top: 1rpx solid #e2e2e2;
border-bottom: 1rpx solid #e2e2e2;
background: #fbfbfb;
box-sizing: border-box;
}
.pedigree-column,
.member-node {
display: grid;
min-width: 0;
min-height: 100%;
grid-template-rows: 144rpx 274rpx minmax(0, 1fr);
border-right: 1rpx solid #e2e2e2;
box-sizing: border-box;
color: #38312d;
}
.pedigree-column--legend {
border-left: 1rpx solid #e2e2e2;
background: #f6f6f6;
color: #665d56;
}
.pedigree-column__relation,
.pedigree-column__name,
.pedigree-column__detail,
.member-node__relation,
.member-node__name,
.member-node__copy {
display: flex;
justify-content: center;
box-sizing: border-box;
writing-mode: vertical-rl;
text-orientation: upright;
}
.pedigree-column__relation,
.member-node__relation {
align-items: center;
border-bottom: 1rpx solid #e2e2e2;
font-size: 22rpx;
}
.pedigree-column__name,
.member-node__name {
align-items: center;
border-bottom: 1rpx solid #e2e2e2;
color: #2f2925;
font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx;
font-weight: 700;
}
.pedigree-column__detail,
.member-node__copy {
align-items: flex-start;
gap: 18rpx;
padding: 28rpx 20rpx;
font-size: 21rpx;
line-height: 1.7;
white-space: normal;
}
.pedigree-column__detail {
font-size: 20rpx;
}
.member-node__surface {
display: contents;
}
.member-node--blank {
pointer-events: none;
}
.member-node--selected .member-node__relation,
.member-node--selected .member-node__name,
.member-node--selected .member-node__copy {
background: rgba(181, 46, 34, 0.06);
}
.member-node--selected .member-node__name {
color: #a7241d;
}
.member-node--spouse .member-node__relation {
color: #7a4d43;
}
.node-relation {
color: #675f57;
}
.node-years {
color: #3f3934;
}
.generation-rail {
width: 92rpx;
border-left: 1rpx solid #e2e2e2;
background: rgba(252, 252, 252, 0.96);
box-sizing: border-box;
}
.generation-band {
display: flex;
width: 72rpx;
min-height: 250rpx;
margin: 250rpx auto 0;
padding: 12rpx 0;
flex-direction: column;
align-items: center;
justify-content: space-between;
border: 1rpx solid #b52e22;
background: rgba(255, 255, 255, 0.88);
box-sizing: border-box;
}
.generation-band__arrow {
width: 26rpx;
height: 26rpx;
filter: sepia(1) saturate(3) hue-rotate(318deg);
transform: rotate(90deg);
}
.generation-band__arrow--previous {
transform: rotate(-90deg);
}
.generation-band__copy {
display: flex;
gap: 12rpx;
flex-direction: column;
align-items: center;
color: #a7241d;
font-size: 21rpx;
writing-mode: vertical-rl;
text-orientation: upright;
}
.generation-band__copy text:last-child {
font-size: 18rpx;
}
.pedigree-state-card {
display: grid;
width: calc(100% - 48rpx);
min-height: 360rpx;
margin: 150rpx 24rpx 0;
text-align: center;
}
.pedigree-state-card__skin {
width: 100%;
height: 100%;
}
.pedigree-state-card__skin,
.pedigree-state-card__content {
grid-area: 1 / 1;
}
.pedigree-state-card__content {
z-index: 1;
padding: 62rpx 58rpx 42rpx;
}
.pedigree-state-card__eyebrow,
.pedigree-state-card__title,
.pedigree-state-card__copy {
display: block;
}
.pedigree-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 3rpx;
}
.pedigree-state-card__title {
margin-top: 12rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 34rpx;
font-weight: 700;
}
.pedigree-state-card__copy {
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.pedigree-state-card__action {
display: grid;
width: 360rpx;
min-height: 70rpx;
margin: 22rpx auto 0;
}
.pedigree-state-card__action image,
.pedigree-state-card__action text {
grid-area: 1 / 1;
}
.pedigree-state-card__action image {
width: 100%;
height: 100%;
}
.pedigree-state-card__action text {
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
color: #fff9ed;
font-size: 24rpx;
font-weight: 700;
}
</style>