This commit is contained in:
owenchen committed 2026-10-15 23:20:36 +08:00
1 parent dcc112c076
commit b858386e86
15 files changed
+3634 -69

No files matched your search

@@ -0,0 +1,578 @@
<script setup lang="ts">
/**
* 族谱图(原生递归树容器):全平台不依赖 @antv/g6,用 scroll-view + 递归 family-tree-node 绘制。
*
* 一个组件承载两种数据源(对齐旧 WebVue 的两张 G6 图):
* - mode="sub"(谱系图):根 = 当前族员(props.root.CurrentPerson),子树按需懒加载——
* 点未加载的子女 emit("select-child"),页面拉完写进 props.loadedNodes 再回来展开;
* - mode="full"(祖谱系图):组件内部 FindAllByTreeId 拉全族三张表(人员/夫妻/亲子)拍平索引,
* 从始祖向下渲染;同一孩子挂多个父时只在「承嗣/主承担」父下展开,其余父仅显示引用卡(对齐旧图)。
*
* 展开态、性别/关系文案、头像查表都集中在这里,node 只经 inject 的 api 取用(见 types.ts)。
*/
import type { LoadedTreeNode } from "./types";
import type { FamilyTreeChildrenMapViewEntity } from "@/data/auto/familyTreeChildrenMapViewEntity";
import type { FamilyTreeData } from "@/data/auto/familyTreeData";
import type { FamilyTreeSpouseMapViewEntity } from "@/data/auto/familyTreeSpouseMapViewEntity";
import { useLocale } from "uview-pro";
import { computed, provide, ref, watch } from "vue";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import familyTreeService from "@/service/master/familyTreeService";
import { FAMILY_TREE_CHART_API } from "./types";
const props = defineProps({
visible: { type: Boolean, default: false },
/** sub=当前族员子树(懒加载);full=祖谱全树 */
mode: { type: String as () => "sub" | "full", default: "sub" },
treeId: { type: String, default: "" },
/** sub 模式数据源:当前族员所在族谱 */
root: { type: Object as () => FamilyTreeData | null, default: null },
/** sub 模式已展开加载的族员(personId -> 子树) */
loadedNodes: { type: Object as () => Record<string, LoadedTreeNode>, default: () => ({}) },
title: { type: String, default: "" },
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "select-child", personId: string): void;
(e: "pick-person", personId: string): void;
}>();
const { t } = useLocale();
const { getPolicyText } = usePolicyOptions();
/** 展开状态:personId -> 是否展开(子树/全树共用) */
const expanded = ref<Record<string, boolean>>({});
const loading = ref(false);
// ===== 全树模式:FindAllByTreeId 拍平出的索引 =====
interface FullIndex {
rootId: string;
personMap: Map<string, any>;
spousesOf: Map<string, any[]>;
childrenOf: Map<string, any[]>;
parentsOf: Map<string, { parentId: string; relation?: string }[]>;
/** 当前族人 PersonId(FindAllByTreeId 的 CurrentPerson) */
currentId: string;
/** 主系链:父 PersonId -> 该父子女中位于「祖→当前族人」链上的那一个,排序时置顶(旧图主系居首同款) */
lineageChildOf: Map<string, string>;
}
const fullIndex = ref<FullIndex | null>(null);
/** 承祧 / 入嗣 / 收养类关系:孩子过继到该父名下,由其承担后代世系(对齐旧图) */
const ADOPTIVE_RELATIONS = ["C", "E", "F", "G", "H", "I", "J", "K", "L"];
function isMale(gender?: string): boolean {
if (!gender) {
return true;
}
const label = getPolicyText("GenderPolicy", gender).toLowerCase();
return label.includes("男") || label.includes("male") || gender === "M" || gender === "1";
}
function childTag(relationType?: string): string {
return relationType ? getPolicyText("RelationTypePolicy", relationType) || "子女" : "子女";
}
function byOrder(a: { Orderby?: number }, b: { Orderby?: number }): number {
return (a.Orderby ?? 0) - (b.Orderby ?? 0);
}
/** 全族头像查表:sub 用 root.CurrentPerson.FamilyTreePersons + loadedNodes;full 用 personMap */
function getAvatar(personId: string): string {
const subPersons = (props.root?.CurrentPerson?.FamilyTreePersons ?? []) as any[];
const hit = subPersons.find(p => p?.PersonId === personId);
if (hit?.AvatarUrl) {
return hit.AvatarUrl;
}
const loaded = props.loadedNodes[personId];
if (loaded?.person?.AvatarUrl) {
return loaded.person.AvatarUrl;
}
const full = fullIndex.value?.personMap.get(personId);
return full?.AvatarUrl ?? "";
}
/** 性别查表:关系视图里的 Gender 是拥有者(丈夫/父亲)本人的字段,配偶/子女必须回查族人档案拿真实性别 */
function getGender(personId: string): string {
const subPersons = (props.root?.CurrentPerson?.FamilyTreePersons ?? []) as any[];
const hit = subPersons.find(p => p?.PersonId === personId);
if (hit?.Gender) {
return hit.Gender;
}
const loaded = props.loadedNodes[personId];
if (loaded?.person?.Gender) {
return loaded.person.Gender;
}
return fullIndex.value?.personMap.get(personId)?.Gender ?? "";
}
const rootPersonId = computed(() => {
if (props.mode === "full") {
return fullIndex.value?.rootId ?? "";
}
return props.root?.CurrentPerson?.FamilyPerson?.PersonId ?? "";
});
const hasRoot = computed(() => Boolean(rootPersonId.value) && describe(rootPersonId.value) !== null);
/** 把一份「人 + 配偶 + 子女」原始记录归一化成 ResolvedNode(sub:来自 CurrentPerson/loadedNodes) */
function resolveSubNode(personId: string, record: { person: any; spouses: FamilyTreeSpouseMapViewEntity[]; children: FamilyTreeChildrenMapViewEntity[] }, isRoot: boolean) {
const p = record.person;
const self = {
personId,
name: `${p.Surname ?? ""}${p.GenealogyName ?? ""}`,
commonName: p.CommonName ?? "",
gender: p.Gender ?? "",
avatarUrl: p.AvatarUrl ?? getAvatar(personId),
tag: isRoot ? "本人" : "族员",
};
const spouses = [...record.spouses].sort(byOrder).map((s: any) => ({
personId: s.SpouseId,
name: `${s.SpouseSurname ?? ""}${s.SpouseGenealogyName ?? ""}`,
commonName: s.SpouseCommonName ?? "",
gender: getGender(s.SpouseId),
avatarUrl: getAvatar(s.SpouseId),
tag: "配偶",
}));
const children = [...record.children].sort(byOrder).map((c: any) => {
const childId = c.ChildrenId;
const loaded = childId === rootPersonId.value || Boolean(props.loadedNodes[childId]);
return {
personId: childId,
card: {
personId: childId,
name: `${c.ChildrenSurname ?? ""}${c.ChildrenGenealogyName ?? ""}`,
commonName: c.ChildrenCommonName ?? "",
gender: getGender(childId),
avatarUrl: getAvatar(childId),
tag: childTag(c.RelationType),
},
loaded,
expandable: loaded,
};
});
return { self, spouses, children, loaded: true };
}
/** 全树节点归一化:索引数据恒已加载,expandable 由「主承担父」判定 */
function resolveFullNode(personId: string): any | null {
const index = fullIndex.value;
if (!index) {
return null;
}
const info = index.personMap.get(personId);
if (!info) {
return null;
}
const isRoot = personId === index.rootId;
const self = {
personId,
name: `${info.Surname ?? ""}${info.GenealogyName ?? ""}`,
commonName: info.CommonName ?? "",
gender: info.Gender ?? "",
avatarUrl: getAvatar(personId),
tag: isRoot ? "始祖" : "族员",
};
const spouses = [...(index.spousesOf.get(personId) ?? [])].sort(byOrder).map((s: any) => ({
personId: s.id,
name: s.name,
commonName: s.commonName ?? "",
gender: s.gender || index.personMap.get(s.id)?.Gender || "",
avatarUrl: getAvatar(s.id),
tag: "配偶",
}));
// 主系置顶:链向当前族人的那个子女排第 1,其余兄弟依原始顺序跟在后
const spineId = index.lineageChildOf.get(personId);
const children = [...(index.childrenOf.get(personId) ?? [])].sort((a: any, b: any) => {
if (spineId && a.id === spineId && b.id !== spineId) {
return -1;
}
if (spineId && b.id === spineId && a.id !== spineId) {
return 1;
}
return byOrder(a, b);
}).map((c: any) => {
const primary = primaryParentOf(c.id);
return {
personId: c.id,
card: {
personId: c.id,
name: c.name,
commonName: c.commonName ?? "",
gender: c.gender || index.personMap.get(c.id)?.Gender || "",
avatarUrl: getAvatar(c.id),
tag: childTag(c.relation),
},
loaded: true,
expandable: primary === personId,
};
});
return { self, spouses, children, loaded: true };
}
function primaryParentOf(personId: string): string {
const parents = fullIndex.value?.parentsOf.get(personId) ?? [];
if (parents.length === 0) {
return "";
}
const adopted = parents.find(p => ADOPTIVE_RELATIONS.includes(p.relation ?? ""));
return (adopted ?? parents[0]).parentId;
}
/** 供 node 调用:按模式解析某人;无法解析返回 null */
function describe(personId: string) {
if (!personId) {
return null;
}
if (props.mode === "full") {
return resolveFullNode(personId);
}
if (personId === rootPersonId.value) {
const cp = props.root?.CurrentPerson;
if (!cp?.FamilyPerson) {
return null;
}
return resolveSubNode(personId, {
person: cp.FamilyPerson,
spouses: cp.FamilyTreeSpouseMaps ?? [],
children: cp.FamilyTreeChildrenMaps ?? [],
}, true);
}
const loaded = props.loadedNodes[personId];
if (loaded) {
return resolveSubNode(personId, loaded, false);
}
return null;
}
function toggle(personId: string): void {
expanded.value = { ...expanded.value, [personId]: !expanded.value[personId] };
}
// ===== 缩放与布局方向(弹窗右上控制条)=====
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.1;
const zoom = ref(1);
// 默认纵向:世代向下推进、主系居首列(⇅ 可切回横向阶梯)
const vertical = ref(true);
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
function clampZoom(value: number): number {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value * 10) / 10));
}
function zoomIn(): void {
zoom.value = clampZoom(zoom.value + ZOOM_STEP);
}
function zoomOut(): void {
zoom.value = clampZoom(zoom.value - ZOOM_STEP);
}
function toggleDirection(): void {
vertical.value = !vertical.value;
}
function requestLoad(personId: string): void {
emit("select-child", personId);
}
function requestPick(personId: string): void {
emit("pick-person", personId);
}
provide(FAMILY_TREE_CHART_API, {
describe,
expanded,
toggle,
requestLoad,
requestPick,
isMale,
vertical,
});
/** 全树:从 FamilyTreeData 拍平三张索引表(照抄旧 buildIndex 的字段映射) */
function buildFullIndex(resp: FamilyTreeData): FullIndex {
const personMap = new Map<string, any>();
const spousesOf = new Map<string, any[]>();
const childrenOf = new Map<string, any[]>();
const parentsOf = new Map<string, { parentId: string; relation?: string }[]>();
const cp = resp?.CurrentPerson;
(cp?.FamilyTreePersons ?? []).forEach((p: any) => {
if (p?.PersonId) {
personMap.set(p.PersonId, { ...p });
}
});
const ancestor = cp?.FamilyPerson;
if (ancestor?.PersonId && !personMap.has(ancestor.PersonId)) {
personMap.set(ancestor.PersonId, { ...ancestor });
}
const pushSpouse = (ownerId: string, entry: any) => {
if (!ownerId) {
return;
}
const list = spousesOf.get(ownerId) ?? [];
if (!list.some(x => x.id === entry.id)) {
list.push(entry);
spousesOf.set(ownerId, list);
}
};
(cp?.FamilyTreeSpouseMaps ?? []).forEach((s: any) => {
pushSpouse(s.PersonId, {
id: s.SpouseId,
name: `${s.SpouseSurname ?? ""}${s.SpouseGenealogyName ?? ""}`,
commonName: s.SpouseCommonName,
// 不取 s.Gender:那是拥有者本人的性别,配偶真实性别由 personMap 回查(见 resolveFullNode)
gender: undefined,
orderby: s.Orderby,
});
pushSpouse(s.SpouseId, {
id: s.PersonId,
name: `${s.Surname ?? ""}${s.GenealogyName ?? ""}`,
commonName: s.CommonName,
gender: undefined,
orderby: s.Orderby,
});
});
(cp?.FamilyTreeChildrenMaps ?? []).forEach((c: any) => {
const list = childrenOf.get(c.PersonId) ?? [];
if (!list.some(x => x.id === c.ChildrenId)) {
list.push({
id: c.ChildrenId,
name: `${c.ChildrenSurname ?? ""}${c.ChildrenGenealogyName ?? ""}`,
commonName: c.ChildrenCommonName,
// 同上:c.Gender 是父亲的性别,子女真实性别由 personMap 回查
gender: undefined,
relation: c.RelationType,
orderby: c.Orderby,
});
childrenOf.set(c.PersonId, list);
const parentList = parentsOf.get(c.ChildrenId) ?? [];
parentList.push({ parentId: c.PersonId, relation: c.RelationType });
parentsOf.set(c.ChildrenId, parentList);
}
});
const rootId = ancestor?.PersonId || resp?.FamilyTree?.AncestorPersonId || "";
// 主系链:从当前族人逐级向上到始祖,记下每一环「父 -> 链上子」,渲染时把该子排到子女首位。
// 锚点优先用页面当前选中族人(props.root,如切到表妹);FindAllByTreeId 的 CurrentPerson 是登录者本人,仅作兜底
const currentId = props.root?.CurrentPerson?.FamilyPerson?.PersonId || cp?.FamilyPerson?.PersonId || "";
const lineageChildOf = new Map<string, string>();
const visited = new Set<string>();
let cursor = currentId;
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
const parentId = (parentsOf.get(cursor) ?? [])[0]?.parentId ?? "";
if (!parentId) {
break;
}
lineageChildOf.set(parentId, cursor);
cursor = parentId;
}
return { rootId, personMap, spousesOf, childrenOf, parentsOf, currentId, lineageChildOf };
}
async function loadFull(): Promise<void> {
const treeId = props.treeId.trim();
if (!treeId) {
fullIndex.value = null;
return;
}
try {
loading.value = true;
const resp = await familyTreeService.FindAllByTreeId(treeId);
if (resp?.CurrentPerson) {
fullIndex.value = buildFullIndex(resp);
}
else {
fullIndex.value = null;
}
}
catch (error) {
console.error(error);
fullIndex.value = null;
}
finally {
loading.value = false;
}
}
/** 弹窗打开:重置展开态到根节点;full 默认全树展开(旧祖谱系图同款),sub 仅展开根待懒加载 */
watch(
() => props.visible,
(on) => {
if (!on) {
return;
}
expanded.value = {};
if (props.mode === "full") {
loadFull().then(() => {
const next: Record<string, boolean> = {};
if (rootPersonId.value) {
next[rootPersonId.value] = true;
}
// 所有有子女的族员一律展开;非主承担父由 node 的 expandable 门控,不会重复渲染子树
fullIndex.value?.childrenOf.forEach((_list: any[], parentId: string) => {
next[parentId] = true;
});
expanded.value = next;
});
}
else if (rootPersonId.value) {
expanded.value = { [rootPersonId.value]: true };
}
},
);
/** sub 模式:新加载的族员自动展开,方便直接看其子树(对齐旧图 watch loadedNodes) */
watch(
() => Object.keys(props.loadedNodes),
(ids) => {
if (!props.visible || props.mode !== "sub") {
return;
}
const next = { ...expanded.value };
for (const id of ids) {
next[id] = true;
}
expanded.value = next;
},
);
function close(): void {
emit("update:visible", false);
}
</script>
<template>
<u-popup :model-value="props.visible" mode="bottom" round="0" :safe-area-inset-bottom="true" @close="close">
<view class="ftc">
<view class="ftc__bar">
<text class="ftc__title">
{{ props.title || t('FamilyTreePage.pedigreeChart') }}
</text>
<u-icon name="close" size="40" color="#8f959e" @click="close" />
</view>
<!-- 悬浮控制条:放大/缩小/百分比复位/横纵布局切换(对齐旧图 G6 工具钮) -->
<view class="ftc__ctrl">
<view class="ftc__ctrl-btn" @tap="zoomIn">
<text class="ftc__ctrl-glyph">+</text>
</view>
<view class="ftc__ctrl-btn ftc__ctrl-btn--label" @tap="zoom = 1">
<text class="ftc__ctrl-label">{{ zoomLabel }}</text>
</view>
<view class="ftc__ctrl-btn" @tap="zoomOut">
<text class="ftc__ctrl-glyph">-</text>
</view>
<view class="ftc__ctrl-btn" @tap="toggleDirection">
<text class="ftc__ctrl-glyph">⇅</text>
</view>
</view>
<scroll-view class="ftc__scroll" scroll-x scroll-y :show-scrollbar="true">
<view v-if="hasRoot" class="ftc__tree" :style="{ zoom }">
<family-tree-node :person-id="rootPersonId" :preview="null" :expandable="true" :depth="0" />
</view>
<view v-else class="ftc__empty">
<text>{{ loading ? t('AdminPage.loadingTip') : t('FamilyTreePage.chartEmpty') }}</text>
</view>
</scroll-view>
</view>
</u-popup>
</template>
<style lang="scss" scoped>
.ftc {
position: relative;
display: flex;
flex-direction: column;
/* 默认满屏:bottom 弹窗内容撞满视口,顶部预留安全区 */
height: 100vh;
background-color: #f5f6f8;
}
.ftc__bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: calc(28rpx + env(safe-area-inset-top)) 32rpx 28rpx;
background-color: #ffffff;
border-bottom: 2rpx solid #f2f3f5;
}
.ftc__title {
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
}
/* 悬浮控制条:右侧竖排,白底圆角轻阴影 */
.ftc__ctrl {
position: absolute;
top: calc(120rpx + env(safe-area-inset-top));
right: 24rpx;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
}
.ftc__ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 76rpx;
border-bottom: 2rpx solid #f2f3f5;
}
.ftc__ctrl-btn--label {
border-bottom-color: #f2f3f5;
}
.ftc__ctrl-glyph {
font-size: 34rpx;
color: #1f2329;
}
.ftc__ctrl-label {
font-size: 24rpx;
color: #646a73;
}
.ftc__scroll {
flex: 1;
min-height: 0;
white-space: nowrap;
}
.ftc__tree {
display: inline-block;
box-sizing: border-box;
/* 按内容实际宽度撑开:inline-block 默认被可用宽度封顶,超出部分只溢出不扩滚动区,
scroll-x 必须靠 max-content 拿到真实宽度才能横向滚起来;窄于容器时由 min-width 撑满 */
width: max-content;
min-width: 100%;
padding: 24rpx;
}
.ftc__empty {
display: flex;
align-items: center;
justify-content: center;
height: 320rpx;
font-size: 26rpx;
color: #8f959e;
}
</style>
@@ -0,0 +1,362 @@
<script setup lang="ts">
/**
* 族谱图递归节点:渲染「一个人 + 其配偶(同行、紫虚线相连)+ 其子女(实折线下钻)」,自引用形成整棵树。
*
* 数据来源全部经 chart 注入的 api(见 types.ts):
* - describe(personId) 命中即已加载,能拿到自身/配偶/子女并继续递归;
* - 命中不到(懒加载子树里还没拉取的子女)就用父节点传下来的 preview 卡片渲染占位,点击 requestLoad。
* 展开态集中在 chart.expanded(personId->bool),本组件只读不算自己的树,避免每层各存一份。
*
* 视觉对齐旧 G6 图:卡片顶粗色条 + 大圆头像同色描边(本人/始祖=橙,男=蓝,女=粉),
* 身份标签为同色浅底 chip,「点击展开/折叠」青底白字、「点击查看」紫底白字 chip。
*/
import type { FamilyCard, FamilyTreeChartApi } from "./types";
import { computed, inject } from "vue";
import { FAMILY_TREE_CHART_API } from "./types";
const props = defineProps({
personId: { type: String, required: true },
/** 未加载时的占位卡片;已加载可留空,由 describe 解析 */
preview: { type: Object as () => FamilyCard | null, default: null },
/** 是否允许就地展开(chart 依据加载态 + 承嗣去重决定) */
expandable: { type: Boolean, default: false },
/** 世代深度,用于逐级左缩进 */
depth: { type: Number, default: 0 },
});
const api = inject<FamilyTreeChartApi>(FAMILY_TREE_CHART_API)!;
const resolved = computed(() => api.describe(props.personId));
const loaded = computed(() => resolved.value !== null);
const card = computed<FamilyCard>(() => resolved.value?.self ?? (props.preview as FamilyCard));
const spouses = computed(() => resolved.value?.spouses ?? []);
const children = computed(() => resolved.value?.children ?? []);
const expanded = computed(() => Boolean(api.expanded.value[props.personId]));
const canToggle = computed(() => loaded.value && props.expandable && children.value.length > 0);
const isSelf = computed(() => card.value.tag === "本人" || card.value.tag === "始祖");
const theme = computed(() => {
if (isSelf.value) {
return "#fa8c16";
}
return api.isMale(card.value.gender) ? "#1890ff" : "#eb2f96";
});
/** 标签 chip 的同色浅底(hex 尾缀透明度,与旧图浅底彩字一致) */
const themeSoft = computed(() => `${theme.value}1a`);
function spouseTheme(sp: FamilyCard): string {
return api.isMale(sp.gender) ? "#1890ff" : "#eb2f96";
}
const actionText = computed(() => {
if (!loaded.value) {
return "点击查看";
}
if (canToggle.value) {
return expanded.value ? "点击折叠" : "点击展开";
}
return "";
});
/** 未加载=紫底「点击查看」,可展开/折叠=青底,与旧图两种按钮同款 */
const actionClass = computed(() => (loaded.value ? "ftn__action--toggle" : "ftn__action--load"));
function onCardTap(): void {
if (!loaded.value) {
api.requestLoad(props.personId);
return;
}
if (canToggle.value) {
api.toggle(props.personId);
return;
}
api.requestPick(props.personId);
}
</script>
<template>
<view class="ftn" :class="{ 'ftn--v': api.vertical.value }">
<!-- 本人 + 配偶:同一行,夫妻间紫色虚线相连 -->
<view class="ftn__row">
<view class="ftn__card" :style="{ borderTopColor: theme }" @tap="onCardTap">
<view class="ftn__head">
<view class="ftn__avatar" :style="{ borderColor: theme }">
<image v-if="card.avatarUrl" class="ftn__avatar-img" :src="card.avatarUrl" mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="theme" />
</view>
<view class="ftn__info">
<text class="ftn__name">
{{ card.name || "-" }}
</text>
<text v-if="card.commonName" class="ftn__sub">
常用名:{{ card.commonName }}
</text>
</view>
</view>
<view class="ftn__foot">
<text class="ftn__tag" :style="{ color: theme, backgroundColor: themeSoft }">
{{ card.tag }}
</text>
<text v-if="actionText" class="ftn__action" :class="actionClass">
{{ actionText }}
</text>
</view>
</view>
<template v-for="sp in spouses" :key="sp.personId">
<view class="ftn__couple-line" />
<view class="ftn__card" :style="{ borderTopColor: spouseTheme(sp) }">
<view class="ftn__head">
<view class="ftn__avatar" :style="{ borderColor: spouseTheme(sp) }">
<image v-if="sp.avatarUrl" class="ftn__avatar-img" :src="sp.avatarUrl" mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="spouseTheme(sp)" />
</view>
<view class="ftn__info">
<text class="ftn__name">
{{ sp.name || "-" }}
</text>
<text v-if="sp.commonName" class="ftn__sub">
常用名:{{ sp.commonName }}
</text>
</view>
</view>
<view class="ftn__foot">
<text class="ftn__tag"
:style="{ color: spouseTheme(sp), backgroundColor: `${spouseTheme(sp)}1a` }">
配偶
</text>
</view>
</view>
</template>
</view>
<!-- 子女:展开时逐级下钻,竖干线 + 横支线实折线连接 -->
<view v-if="expanded && children.length" class="ftn__children">
<view v-for="child in children" :key="child.personId" class="ftn__child">
<family-tree-node :person-id="child.personId" :preview="child.card" :expandable="child.expandable"
:depth="depth + 1" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.ftn {
display: flex;
flex-direction: column;
}
.ftn__row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
}
/* 夫妻虚线:横排连接段,垂直居中于两卡之间(超出弹窗宽度由 chart 的 scroll-x 承载) */
.ftn__couple-line {
flex: none;
align-self: center;
width: 40rpx;
border-top: 3rpx dashed #722ed1;
}
.ftn__card {
box-sizing: border-box;
flex: none;
/* 长宽比约 1.75:1(340 宽,内容自然高 ~190),对齐用户给的卡片样例 */
width: 340rpx;
padding: 20rpx 24rpx;
background: #ffffff;
border: 1px solid #e8e8e8;
border-top-width: 8rpx;
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.ftn__head {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
/* 大圆头像:同色细描边 + 线框人像图标(对齐旧图) */
.ftn__avatar {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
overflow: hidden;
background: #ffffff;
border: 3rpx solid #e8e8e8;
border-radius: 50%;
}
.ftn__avatar-img {
width: 100%;
height: 100%;
}
.ftn__info {
display: flex;
flex-direction: column;
min-width: 0;
}
.ftn__name {
display: block;
overflow: hidden;
font-size: 26rpx;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
text-overflow: ellipsis;
}
.ftn__sub {
display: block;
overflow: hidden;
margin-top: 6rpx;
font-size: 22rpx;
color: #8f959e;
white-space: nowrap;
text-overflow: ellipsis;
}
.ftn__foot {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
/* 身份标签:同色浅底彩字 chip */
.ftn__tag {
padding: 6rpx 16rpx;
font-size: 22rpx;
border-radius: 8rpx;
}
/* 动作按钮 chip:展开/折叠=青,点击查看=紫(旧图同款双色) */
.ftn__action {
padding: 6rpx 16rpx;
font-size: 22rpx;
color: #ffffff;
border-radius: 8rpx;
&--toggle {
background: #13c2c2;
}
&--load {
background: #722ed1;
}
}
/* 子女区:竖干线从父卡中心(170)下方落下;每代统一右移一个卡宽(340)+ 间隙(40),
各世代列左缘严格对齐(旧图同款阶梯),配偶卡正好叠在下代列上方 */
.ftn__children {
display: flex;
flex-direction: column;
gap: 24rpx;
padding-bottom: 8rpx;
padding-left: 207rpx;
margin-top: 24rpx;
margin-left: 170rpx;
border-left: 3rpx solid #c9ced6;
}
.ftn__child {
position: relative;
}
.ftn__child::before {
position: absolute;
top: 70rpx;
left: -207rpx;
width: 203rpx;
content: "";
border-top: 3rpx solid #c9ced6;
}
/* 容器 border-left 会随最后一个子女的子树一路延伸到底;
用画布底色盖掉其 elbow 以下的线段,竖干线停在最后一个子女卡片处(旧图同款) */
.ftn__child:last-child::after {
position: absolute;
top: 74rpx;
bottom: 0;
left: -210rpx;
width: 8rpx;
content: "";
background: #f5f6f8;
}
/* ===== 纵向布局(⇅ 切换):世代向下推进,子女横排,横线干在顶部 ===== */
.ftn--v .ftn__children {
position: relative;
flex-direction: row;
align-items: flex-start;
margin-left: 0;
padding-bottom: 8rpx;
padding-left: 0;
border-top: 3rpx solid #c9ced6;
border-left: none;
}
/* 父卡底部到横线的短连接段(跨容器 margin-top,落在父卡中心 170) */
.ftn--v .ftn__children::before {
position: absolute;
top: -27rpx;
left: 168rpx;
width: 0;
height: 27rpx;
content: "";
border-left: 3rpx solid #c9ced6;
}
/* 子女卡与横线拉开间距,竖支线落在卡中心 */
.ftn--v .ftn__child {
margin-top: 40rpx;
}
.ftn--v .ftn__child::before {
top: -40rpx;
left: 168rpx;
width: 0;
height: 37rpx;
border-top: none;
border-left: 3rpx solid #c9ced6;
}
/* 横线只存在于首尾支线之间:两端用画布底色盖掉多余线段 */
.ftn--v .ftn__child:first-child::after {
position: absolute;
top: -43rpx;
bottom: auto;
left: -24rpx;
width: 195rpx;
height: 8rpx;
content: "";
background: #f5f6f8;
}
.ftn--v .ftn__child:last-child::after {
top: -43rpx;
bottom: auto;
left: 171rpx;
width: auto;
right: -24rpx;
height: 8rpx;
}
/* 独子:横线整段盖掉,只剩父卡连接段接子女竖支线 */
.ftn--v .ftn__child:only-child::after {
left: -24rpx;
width: calc(100% + 48rpx);
right: auto;
}
</style>
@@ -0,0 +1,70 @@
/**
* 族谱图(原生递归树)的共享形状与注入 key
*
* 单列出来只为一个原因:family-tree-chart(持有数据源与展开状态)与
* family-tree-node(递归渲染单个节点)要共享「节点长什么样、页面能调哪些动作」这两个契约,
* 放进任何一个 .vue 里都会变成互相 import 的循环引用。
*/
import type { Ref } from "vue";
import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData";
import type { FamilyTreeChildrenMapViewEntity } from "@/data/auto/familyTreeChildrenMapViewEntity";
import type { FamilyTreeSpouseMapViewEntity } from "@/data/auto/familyTreeSpouseMapViewEntity";
/** 卡片显示所需的最小人员信息(本人 / 配偶 / 子女预览共用) */
export interface FamilyCard {
personId: string;
/** 姓 + 谱名拼出的展示名 */
name: string;
commonName?: string;
gender?: string;
avatarUrl?: string;
/** 左上角身份标签:本人 / 始祖 / 族员 / 配偶 / 子女(或具体关系) */
tag: string;
}
/** 一条子女引用:父节点列出孩子时携带,子节点据此渲染占位或递归 */
export interface ChildRef {
personId: string;
card: FamilyCard;
/** 该子女的数据是否已加载(子树模式懒加载;全树模式恒为 true) */
loaded: boolean;
/** 是否可就地展开(已加载且确有后代、且本父是其「承嗣/主承担」父,避免重复子树) */
expandable: boolean;
}
/** describe(personId) 的产物:一个节点渲染自身所需的全部信息 */
export interface ResolvedNode {
self: FamilyCard;
spouses: FamilyCard[];
children: ChildRef[];
/** self 是否已加载(未加载时只有卡片预览,点击触发 select-child 去拉取) */
loaded: boolean;
}
/** chart 通过 provide 注入给(递归)node 的数据源与动作集 */
export interface FamilyTreeChartApi {
/** 解析某人的节点数据;无法解析(未加载且无预览)返回 null */
describe: (personId: string) => ResolvedNode | null;
/** 展开状态:personId -> 是否展开 */
expanded: Ref<Record<string, boolean>>;
/** 切换展开 */
toggle: (personId: string) => void;
/** 点击「未加载」的子女:通知页面去懒加载其子树 */
requestLoad: (personId: string) => void;
/** 点击「无后代」的人:通知页面把它设为当前族员 */
requestPick: (personId: string) => void;
/** 性别是否男性(空值按男处理,对齐旧图) */
isMale: (gender?: string) => boolean;
/** 布局方向:false=世代向右(横向阶梯),true=世代向下(纵向排列),由容器 ⇅ 按钮切换 */
vertical: Ref<boolean>;
}
/** sub 模式懒加载缓存的一项:某人的原始「人 + 配偶 + 子女」记录 */
export interface LoadedTreeNode {
person: FamilyPersonDisplayData;
spouses: FamilyTreeSpouseMapViewEntity[];
children: FamilyTreeChildrenMapViewEntity[];
}
/** provide / inject 的键 */
export const FAMILY_TREE_CHART_API = "familyTreeChartApi";
@@ -0,0 +1,240 @@
<script setup lang="ts">
/**
* 快速新增家族成员(底部抽屉):旧 WebVue components/master/FamilyPersonQuickCreateModal.vue 的移动端等价物
*
* 族谱关系维护里「新增配偶 / 新增孩子 / 新增祖先」都调它:只填 姓* / 名(谱名)* / 性别*,
* 落库走 FamilyPersonService.Insert(TreeId 由调用方带入),成功后 emit("created", person),
* created 取 result?.FamilyPerson || payload.FamilyPerson,由页面按用途回填到对应关系列表。
* 性别用 option-picker 单选(数据来自 GenderPolicy 语言包),与成员详情页同口径。
*/
import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData";
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonData } from "@/data/auto/familyPersonData";
import { FamilyPersonDisplayData as FamilyPersonDisplayDataCls } from "@/data/auto/familyPersonDisplayData";
import familyPersonService from "@/service/master/familyPersonService";
const props = defineProps({
visible: { type: Boolean, default: false },
treeId: { type: String, default: "" },
title: { type: String, default: "" },
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "created", person: FamilyPersonDisplayData): void;
}>();
const { t } = useLocale();
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
const genderItems = computed(() => getPolicyOptions("GenderPolicy"));
const surname = ref("");
const genealogyName = ref("");
const gender = ref("");
const saving = ref(false);
const genderPickerVisible = ref(false);
const genderLabel = computed(() => getPolicyText("GenderPolicy", gender.value));
watch(
() => props.visible,
(on) => {
if (on) {
surname.value = "";
genealogyName.value = "";
gender.value = "";
}
},
);
function close(): void {
emit("update:visible", false);
}
function onGenderConfirm(key: string): void {
gender.value = key;
genderPickerVisible.value = false;
}
async function onConfirm(): Promise<void> {
if (!surname.value.trim()) {
uni.showToast({ title: t("FamilyPersonPage.surnameRequired"), icon: "none" });
return;
}
if (!genealogyName.value.trim()) {
uni.showToast({ title: t("FamilyPersonPage.genealogyNameRequired"), icon: "none" });
return;
}
if (!gender.value) {
uni.showToast({ title: t("FamilyPersonPage.genderRequired"), icon: "none" });
return;
}
const payload = new FamilyPersonData();
payload.FamilyPerson = new FamilyPersonDisplayDataCls();
payload.FamilyPerson.Surname = surname.value.trim();
payload.FamilyPerson.GenealogyName = genealogyName.value.trim();
payload.FamilyPerson.Gender = gender.value;
payload.FamilyPerson.TreeId = props.treeId || "";
try {
saving.value = true;
const result = await familyPersonService.Insert(payload);
const created = result?.FamilyPerson || payload.FamilyPerson;
emit("created", created);
close();
}
catch (error) {
console.error(error);
uni.showToast({ title: t("FamilyPersonPage.quickCreateFailed"), icon: "none" });
}
finally {
saving.value = false;
}
}
</script>
<template>
<u-popup :model-value="props.visible" mode="bottom" round="24" :close-on-click-overlay="!saving" @close="close">
<view class="pqc">
<text class="pqc__title">
{{ props.title || t('FamilyPersonPage.quickCreateTitle') }}
</text>
<view class="pqc__field">
<text class="pqc__label">
{{ t("FamilyPersonPage.surname") }}<text class="pqc__required">*</text>
</text>
<u-input v-model="surname" :border="true" :maxlength="100"
:placeholder="t('FamilyPersonPage.surnamePlaceholder')" />
</view>
<view class="pqc__field">
<text class="pqc__label">
{{ t("FamilyPersonPage.genealogyName") }}<text class="pqc__required">*</text>
</text>
<u-input v-model="genealogyName" :border="true" :maxlength="100"
:placeholder="t('FamilyPersonPage.genealogyNamePlaceholder')" />
</view>
<view class="pqc__field">
<text class="pqc__label">
{{ t("FamilyPersonPage.gender") }}<text class="pqc__required">*</text>
</text>
<view class="pqc__picker" @tap="genderPickerVisible = true">
<text :class="{ 'pqc__picker-empty': !genderLabel }">
{{ genderLabel || t('FamilyPersonPage.genderPlaceholder') }}
</text>
<u-icon name="arrow-right" size="26" color="#c4c8cf" />
</view>
</view>
<view class="pqc__footer">
<view class="pqc__btn" @tap="close">
<text class="pqc__btn-text">
{{ t("AdminPage.cancelBut") }}
</text>
</view>
<view class="pqc__btn pqc__btn--primary" :class="{ 'pqc__btn--muted': saving }" @tap="onConfirm">
<text class="pqc__btn-text pqc__btn-text--primary">
{{ saving ? t("AdminPage.savingTip") : t("AdminPage.confirmBut") }}
</text>
</view>
</view>
<view class="pqc__safe" />
</view>
<option-picker :visible="genderPickerVisible" :title="t('FamilyPersonPage.gender')" :items="genderItems"
:empty-text="t('FamilyPersonPage.genderEmpty')" @close="genderPickerVisible = false"
@confirm="onGenderConfirm" />
</u-popup>
</template>
<style lang="scss" scoped>
.pqc {
padding: 40rpx 32rpx 0;
background-color: #ffffff;
&__title {
display: block;
padding-bottom: 24rpx;
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
text-align: center;
}
&__field {
margin-bottom: 24rpx;
}
&__label {
display: block;
margin-bottom: 12rpx;
font-size: 26rpx;
color: #646a73;
}
&__required {
margin-left: 2rpx;
color: #ff4d4f;
}
&__picker {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 76rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1f2329;
border: 1px solid #e5e6eb;
border-radius: 12rpx;
}
&__picker-empty {
color: #8f959e;
}
&__footer {
display: flex;
gap: 20rpx;
margin-top: 8rpx;
}
&__btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 76rpx;
border-radius: 999rpx;
background-color: #f5f6f8;
&--primary {
background-color: #2b8fff;
}
&--muted {
background-color: #b9d6ff;
}
}
&__btn-text {
font-size: 28rpx;
color: #5c626b;
&--primary {
color: #ffffff;
}
}
&__safe {
height: calc(32rpx + env(safe-area-inset-bottom));
}
}
</style>