582 lines
18 KiB
Vue
582 lines
18 KiB
Vue
<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="getPedigreeSheetStyle(page)">
|
|
<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 || `member-${pageIndex}-${memberIndex}`"
|
|
class="member-node"
|
|
:class="{
|
|
'member-node--selected': selectedId === String(member.id),
|
|
'member-node--spouse': Boolean(member.spouseOf),
|
|
}"
|
|
>
|
|
<view class="member-node__surface">
|
|
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
|
|
<view
|
|
class="member-node__name"
|
|
:class="{
|
|
'member-node__name--horizontal': !isVerticalPedigreeText(member.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>{{ memberDetailText(member) }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
</swiper-item>
|
|
</swiper>
|
|
|
|
<view class="generation-rail" :style="pedigreeHeightStyle">
|
|
<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/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/scroll-primary.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 { onBackPress, 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 {
|
|
createRequestController,
|
|
isRequestCancelled
|
|
} from "@/services/api/request-controller.js";
|
|
import { lineageApi } from "@/services/api/lineage-service.js";
|
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
|
import { genealogyContext } from "@/utils/genealogy/context.js";
|
|
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
|
|
|
const PEDIGREE_PAGE_SIZE = 3;
|
|
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 pedigreeDetailRequestController = 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 isVerticalPedigreeText = (value) =>
|
|
/^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff·〇零一二三四五六七八九十百千万]+$/.test(
|
|
String(value || ""),
|
|
);
|
|
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);
|
|
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(memberDetailText(member)).length,
|
|
),
|
|
),
|
|
);
|
|
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
|
|
const pedigreeHeightStyle = computed(() => ({
|
|
height: `${sheetHeight.value}rpx`,
|
|
}));
|
|
const getPedigreeSheetStyle = (page) => ({
|
|
...pedigreeHeightStyle.value,
|
|
gridTemplateColumns: `104rpx repeat(${page.members.length}, minmax(0, 1fr))`,
|
|
});
|
|
const pedigreeScrollStyle = computed(() => ({
|
|
height: `${sheetHeight.value}rpx`,
|
|
}));
|
|
const stateCopy = computed(
|
|
() =>
|
|
({
|
|
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.treeYears || member.years || "生卒待补";
|
|
const memberDetailText = (member) =>
|
|
[member.branch, memberDetail(member)].filter(Boolean).join("·");
|
|
const openMemberProfile = (member) =>
|
|
openPage(
|
|
"T03",
|
|
{ genealogyId: genealogyId.value, personId: String(member.id) },
|
|
"T02",
|
|
);
|
|
const closeMemberDetail = () => {
|
|
detailVisible.value = false;
|
|
pedigreeDetailRequestController.abort();
|
|
};
|
|
const openMemberDetail = async (member) => {
|
|
if (!member) return;
|
|
const generation = ++detailLoadGeneration;
|
|
pedigreeDetailRequestController.abort();
|
|
detailMember.value = member;
|
|
detailMessage.value = "正在读取人物生平…";
|
|
detailVisible.value = true;
|
|
try {
|
|
const detail = await lineageApi.getPerson(genealogyId.value, member.id, {
|
|
requestController: pedigreeDetailRequestController,
|
|
});
|
|
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 = getRequestErrorMessage(error, "人物生平读取失败,请稍后重试。");
|
|
}
|
|
};
|
|
const toTree = () => {
|
|
const params = { genealogyId: genealogyId.value };
|
|
if (selectedId.value) params.selectedId = selectedId.value;
|
|
return returnTo("T01", params);
|
|
};
|
|
onBackPress((event) =>
|
|
detailVisible.value ? handleBackPress(event, closeMemberDetail) : false,
|
|
);
|
|
const handleStateAction = () => {
|
|
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 (!/^[1-9]\d*$/.test(genealogyId.value)) {
|
|
members.value = [];
|
|
treeState.value = "empty";
|
|
return;
|
|
}
|
|
treeState.value = "loading";
|
|
try {
|
|
const treeMembers = await lineageApi.getTree(genealogyId.value, {
|
|
requestController: pedigreeRequestController,
|
|
});
|
|
if (!pageActive || generation !== loadGeneration) return;
|
|
members.value = treeMembers;
|
|
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 = getRequestErrorMessage(error, "世系谱读取失败,请稍后重试。");
|
|
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();
|
|
pedigreeDetailRequestController.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;
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
box-sizing: border-box;
|
|
}
|
|
.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: clamp(14px, 22rpx, 17px);
|
|
}
|
|
.pedigree-column__name,
|
|
.member-node__name {
|
|
align-items: center;
|
|
border-bottom: 1rpx solid #e2e2e2;
|
|
color: #2f2925;
|
|
font-family: "STKaiti", "KaiTi", serif;
|
|
font-size: clamp(19px, 34rpx, 24px);
|
|
font-weight: 700;
|
|
}
|
|
.member-node__name--horizontal {
|
|
padding: 12rpx 8rpx;
|
|
line-height: 1.2;
|
|
overflow-wrap: anywhere;
|
|
text-align: center;
|
|
text-orientation: mixed;
|
|
writing-mode: horizontal-tb;
|
|
}
|
|
.pedigree-column__detail,
|
|
.member-node__copy {
|
|
align-items: flex-start;
|
|
gap: 18rpx;
|
|
padding: 28rpx 20rpx;
|
|
font-size: clamp(13px, 21rpx, 16px);
|
|
line-height: 1.7;
|
|
white-space: normal;
|
|
}
|
|
.pedigree-column__detail {
|
|
font-size: clamp(13px, 20rpx, 16px);
|
|
}
|
|
.member-node__surface {
|
|
display: contents;
|
|
}
|
|
.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;
|
|
}
|
|
.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: clamp(13px, 21rpx, 16px);
|
|
writing-mode: vertical-rl;
|
|
text-orientation: upright;
|
|
}
|
|
.generation-band__copy text:last-child {
|
|
font-size: clamp(12px, 18rpx, 14px);
|
|
}
|
|
.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: clamp(14px, 22rpx, 17px);
|
|
letter-spacing: 3rpx;
|
|
}
|
|
.pedigree-state-card__title {
|
|
margin-top: 12rpx;
|
|
color: $ink;
|
|
font-family: "STKaiti", "KaiTi", serif;
|
|
font-size: clamp(19px, 34rpx, 24px);
|
|
font-weight: 700;
|
|
}
|
|
.pedigree-state-card__copy {
|
|
margin-top: 16rpx;
|
|
color: $ink-muted;
|
|
font-size: clamp(15px, 24rpx, 18px);
|
|
line-height: 1.65;
|
|
}
|
|
.pedigree-state-card__action {
|
|
display: grid;
|
|
width: 360rpx;
|
|
min-height: var(--app-touch-min);
|
|
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: clamp(15px, 24rpx, 18px);
|
|
font-weight: 700;
|
|
}
|
|
</style>
|