待测试
This commit is contained in:
+133
-56
@@ -1,73 +1,150 @@
|
||||
<!-- 页面编号:F-10;用途:家族视频。当前缺读取、发布和播放业务 owner。 -->
|
||||
<template>
|
||||
<view class="video-page"
|
||||
><ModulePageBackground module="family" /><view class="page-header"
|
||||
><PageHeader title="家族视频" custom-back @back="returnToFamily" /></view
|
||||
><view class="page-content"
|
||||
><view class="state-card"
|
||||
><text>家族视频暂未开放</text
|
||||
><text>视频功能正在准备中。开放后,家人的影像会在这里展示。</text
|
||||
><AppButton
|
||||
block
|
||||
:label="hasValidContext ? '返回家族动态' : '返回上一页'"
|
||||
@click="returnToFamily" /></view></view
|
||||
></view>
|
||||
<view class="video-page" :class="`video-state--${pageState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="家族视频" custom-back @back="returnToFamily" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="video-panel">
|
||||
<text class="video-panel__title">发布家族视频</text>
|
||||
<text class="video-panel__note">视频文件由上传回执自动关联,发布时不需要填写文件 ID。</text>
|
||||
|
||||
<view class="video-field video-field--upload">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频文件</text>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="selectVideo">
|
||||
{{ uploading ? "上传中…" : receipt ? "重新选择视频" : "选择视频" }}
|
||||
</button>
|
||||
<text v-if="receipt" class="upload-receipt">已上传:{{ receipt.fileName || "视频" }}</text>
|
||||
</view>
|
||||
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||
|
||||
<view class="video-field">
|
||||
<text class="video-field__label"><text class="required-mark">*</text>视频标题</text>
|
||||
<input v-model="form.videoTitle" maxlength="100" placeholder="例如:2026 年清明祭祖活动" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<view class="video-field video-field--textarea">
|
||||
<text class="video-field__label">视频说明</text>
|
||||
<textarea v-model="form.videoDesc" maxlength="500" auto-height placeholder="补充视频中的人物、场景或故事" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
</view>
|
||||
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在发布…' : '发布视频'" @click="submitVideo" />
|
||||
</view>
|
||||
|
||||
<view v-else class="video-state-card">
|
||||
<text class="video-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="video-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isVideoPickCancelled, pickAndUploadVideo } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const receipt = ref(null);
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const form = reactive({ videoTitle: "", videoDesc: "" });
|
||||
const controller = createRequestController();
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const stateCopy = computed(() => pageState.value === "success"
|
||||
? {
|
||||
title: "视频已提交服务端",
|
||||
copy: "视频已按上传回执关联到当前家谱。视频列表接口尚未提供可消费的返回字段,因此此处不猜测播放地址或卡片内容。",
|
||||
action: "返回家族动态",
|
||||
}
|
||||
: {
|
||||
title: "视频入口无效",
|
||||
copy: "没有取得有效的家谱标识。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) pageState.value = "invalid";
|
||||
});
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
onUnmounted(() => controller.abort());
|
||||
|
||||
const selectVideo = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
receipt.value = await pickAndUploadVideo({ requestController: controller });
|
||||
} catch (error) {
|
||||
if (!isVideoPickCancelled(error) && !isRequestCancelled(error)) {
|
||||
uploadError.value = error?.message || "视频上传失败,请稍后重试";
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submitVideo = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
const videoTitle = form.videoTitle.trim();
|
||||
if (!receipt.value) {
|
||||
submitError.value = "请先选择并上传视频";
|
||||
return;
|
||||
}
|
||||
if (!videoTitle) {
|
||||
submitError.value = "请填写视频标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createVideo(genealogyId.value, {
|
||||
videoTitle,
|
||||
videoDesc: form.videoDesc.trim(),
|
||||
videoOssId: receipt.value.ossId,
|
||||
}, { requestController: controller });
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "视频发布失败,请稍后重试";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToFamily = () => hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => returnToFamily();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.video-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive.adaptive-family-content;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.video-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
|
||||
.video-panel, .video-state-card { box-sizing: border-box; @include adaptive.adaptive-family-content; }
|
||||
.video-panel { padding: 30rpx; }
|
||||
.video-panel__title, .video-panel__note, .video-field__label, .video-state-card text { display: block; }
|
||||
.video-panel__title, .video-state-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; }
|
||||
.video-panel__note, .video-state-card__copy { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
|
||||
.video-field { margin-top: 24rpx; }
|
||||
.video-field__label { margin-bottom: 10rpx; color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.video-field input, .video-field textarea { width: 100%; box-sizing: border-box; border: 1rpx solid rgba(143, 108, 63, .34); border-radius: 8rpx; background: rgba(255, 253, 247, .8); color: $ink; font-size: 24rpx; }
|
||||
.video-field input { height: 76rpx; padding: 0 18rpx; }
|
||||
.video-field textarea { min-height: 140rpx; padding: 16rpx 18rpx; }
|
||||
.video-field--upload { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; }
|
||||
.video-field--upload .video-field__label { width: 100%; }
|
||||
.upload-button { margin: 0; padding: 0 26rpx; border: 1rpx solid #b78a42; border-radius: 8rpx; background: #fffaf0; color: #805723; font-size: 23rpx; line-height: 64rpx; }
|
||||
.upload-receipt { color: $ink-muted; font-size: 21rpx; }
|
||||
.required-mark, .field-error { color: $brand-red; }
|
||||
.field-error { display: block; margin-top: 12rpx; font-size: 22rpx; }
|
||||
.video-panel .app-button { margin-top: 28rpx; }
|
||||
.video-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.video-state-card .app-button { margin-top: 28rpx; }
|
||||
</style>
|
||||
|
||||
@@ -91,7 +91,6 @@ const form = reactive({
|
||||
phone: "",
|
||||
relationDesc: "",
|
||||
applyReason: "",
|
||||
inviterUserId: "",
|
||||
});
|
||||
const fields = [
|
||||
{
|
||||
@@ -120,13 +119,6 @@ const fields = [
|
||||
maxlength: 500,
|
||||
placeholder: "说明申请加入的原因",
|
||||
},
|
||||
{
|
||||
key: "inviterUserId",
|
||||
label: "邀请人编号",
|
||||
inputType: "number",
|
||||
maxlength: 20,
|
||||
placeholder: "如有邀请人可填写编号",
|
||||
},
|
||||
];
|
||||
const controller = createRequestController();
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
|
||||
@@ -48,13 +48,21 @@
|
||||
placeholder-class="placeholder"
|
||||
:disabled="saving || uploading"
|
||||
/></view>
|
||||
<view class="form-row form-row--readonly"
|
||||
><text>性别</text
|
||||
><view class="readonly-value"
|
||||
><text>{{ form.sex ? "待确认" : "未设置" }}</text
|
||||
><text>选项待提供</text></view
|
||||
></view
|
||||
>
|
||||
<view class="form-row">
|
||||
<text>性别</text>
|
||||
<picker
|
||||
:range="sexOptions"
|
||||
range-key="label"
|
||||
:value="sexIndex"
|
||||
:disabled="saving || uploading"
|
||||
@change="changeSex"
|
||||
><view
|
||||
class="picker-value"
|
||||
:class="{ 'picker-value--placeholder': !form.sex }"
|
||||
>{{ sexOptions[sexIndex].label }}</view
|
||||
></picker
|
||||
>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>生日</text>
|
||||
<picker
|
||||
@@ -142,6 +150,17 @@ const feedbackMessage = ref("");
|
||||
const requestController = createRequestController();
|
||||
let feedbackTimer = null;
|
||||
let pageActive = true;
|
||||
const sexOptions = [
|
||||
{ value: "", label: "请选择" },
|
||||
{ value: "0", label: "男" },
|
||||
{ value: "1", label: "女" },
|
||||
{ value: "2", label: "未知" },
|
||||
];
|
||||
|
||||
const sexIndex = computed(() => {
|
||||
const index = sexOptions.findIndex((option) => option.value === form.sex);
|
||||
return index === -1 ? 0 : index;
|
||||
});
|
||||
|
||||
const avatarMessage = computed(() => {
|
||||
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
|
||||
@@ -190,6 +209,10 @@ const changeBirthday = (event) => {
|
||||
form.birthday = event?.detail?.value || "";
|
||||
};
|
||||
|
||||
const changeSex = (event) => {
|
||||
form.sex = sexOptions[Number(event?.detail?.value)]?.value || "";
|
||||
};
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
if (uploading.value || saving.value) return;
|
||||
uploading.value = true;
|
||||
@@ -359,22 +382,6 @@ onUnload(() => {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.readonly-value {
|
||||
display: flex;
|
||||
min-height: 68rpx;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12rpx;
|
||||
color: $ink-muted;
|
||||
}
|
||||
.readonly-value text:first-child {
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.readonly-value text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: 19rpx;
|
||||
}
|
||||
.picker-value--placeholder,
|
||||
.placeholder {
|
||||
color: #ab9a86;
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
<view class="type-grid">
|
||||
<button
|
||||
v-for="type in feedbackTypes"
|
||||
:key="type"
|
||||
:key="type.value"
|
||||
class="type-chip"
|
||||
:class="{ active: feedbackForm.feedbackType === type }"
|
||||
:aria-pressed="feedbackForm.feedbackType === type"
|
||||
:class="{ active: feedbackForm.feedbackType === type.value }"
|
||||
:aria-pressed="feedbackForm.feedbackType === type.value"
|
||||
:disabled="feedbackState === 'submitting'"
|
||||
@click="selectFeedbackType(type)"
|
||||
@click="selectFeedbackType(type.value)"
|
||||
>
|
||||
{{ type }}
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -131,7 +131,12 @@ import {
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const feedbackTypes = ["功能问题", "使用建议", "内容纠错", "其他"];
|
||||
const feedbackTypes = [
|
||||
{ value: "bug", label: "功能问题" },
|
||||
{ value: "advice", label: "使用建议" },
|
||||
{ value: "complaint", label: "投诉反馈" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
const feedbackForm = reactive({
|
||||
feedbackType: "",
|
||||
feedbackContent: "",
|
||||
|
||||
@@ -89,26 +89,6 @@
|
||||
@input="clearError"
|
||||
/>
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">经度</text>
|
||||
<input
|
||||
v-model="form.longitude"
|
||||
type="digit"
|
||||
placeholder="地图选点后填写"
|
||||
placeholder-class="placeholder"
|
||||
@input="clearError"
|
||||
/>
|
||||
</view>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">纬度</text>
|
||||
<input
|
||||
v-model="form.latitude"
|
||||
type="digit"
|
||||
placeholder="地图选点后填写"
|
||||
placeholder-class="placeholder"
|
||||
@input="clearError"
|
||||
/>
|
||||
</view>
|
||||
<view class="cover-field">
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
@@ -200,8 +180,6 @@ const form = reactive({
|
||||
ceremonyClock: "",
|
||||
location: "",
|
||||
locationAddress: "",
|
||||
longitude: "",
|
||||
latitude: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const coverOssId = ref(null);
|
||||
@@ -289,8 +267,6 @@ const submit = async () => {
|
||||
{
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(form.longitude ? { longitude: Number(form.longitude) } : {}),
|
||||
...(form.latitude ? { latitude: Number(form.latitude) } : {}),
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
},
|
||||
{ requestController: controller },
|
||||
|
||||
@@ -301,42 +301,134 @@ const nodeRelationText = (member) => {
|
||||
return [member.relation, branch].filter(Boolean).join(" · ");
|
||||
};
|
||||
const layoutMembers = computed(() => {
|
||||
const generations = Array.from(
|
||||
new Set(members.value.map((member) => Number(member.generation))),
|
||||
).sort((left, right) => left - right);
|
||||
const groups = new Map(
|
||||
generations.map((generation) => [
|
||||
generation,
|
||||
members.value
|
||||
.filter((member) => Number(member.generation) === generation)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
String(left.parentId || "").localeCompare(
|
||||
String(right.parentId || ""),
|
||||
) ||
|
||||
String(left.spouseOf || left.id).localeCompare(
|
||||
String(right.spouseOf || right.id),
|
||||
) ||
|
||||
Number(Boolean(left.spouseOf)) - Number(Boolean(right.spouseOf)) ||
|
||||
String(left.id).localeCompare(String(right.id)),
|
||||
),
|
||||
]),
|
||||
const memberById = new Map(
|
||||
members.value.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const maxCount = Math.max(
|
||||
1,
|
||||
...Array.from(groups.values()).map((group) => group.length),
|
||||
);
|
||||
const canvasWidth = Math.max(660, maxCount * MEMBER_GAP + 80);
|
||||
return generations.flatMap((generation, generationIndex) => {
|
||||
const group = groups.get(generation) || [];
|
||||
const occupiedWidth = Math.max(0, (group.length - 1) * MEMBER_GAP);
|
||||
const startX = (canvasWidth - occupiedWidth) / 2;
|
||||
return group.map((member, memberIndex) => ({
|
||||
...member,
|
||||
x: snapToGrid(startX + memberIndex * MEMBER_GAP),
|
||||
y: snapToGrid(200 + generationIndex * GENERATION_GAP),
|
||||
}));
|
||||
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;
|
||||
@@ -395,21 +487,23 @@ const generationBandStyle = (row) => ({
|
||||
});
|
||||
const lineageConnectors = computed(() => {
|
||||
const memberById = new Map(
|
||||
layoutMembers.value.map((member) => [member.id, member]),
|
||||
layoutMembers.value.map((member) => [String(member.id), member]),
|
||||
);
|
||||
const spouseByPersonId = new Map();
|
||||
layoutMembers.value.forEach((member) => {
|
||||
if (member.spouseOf && memberById.has(member.spouseOf)) {
|
||||
spouseByPersonId.set(member.spouseOf, member);
|
||||
const partnerId = String(member.spouseOf || "");
|
||||
if (partnerId && memberById.has(partnerId)) {
|
||||
spouseByPersonId.set(partnerId, member);
|
||||
}
|
||||
});
|
||||
const childrenByParent = new Map();
|
||||
layoutMembers.value.forEach((member) => {
|
||||
if (member.spouseOf || !member.parentId || !memberById.has(member.parentId))
|
||||
const parentId = String(member.parentId || "");
|
||||
if (member.spouseOf || !parentId || !memberById.has(parentId))
|
||||
return;
|
||||
const children = childrenByParent.get(member.parentId) || [];
|
||||
const children = childrenByParent.get(parentId) || [];
|
||||
children.push(member);
|
||||
childrenByParent.set(member.parentId, children);
|
||||
childrenByParent.set(parentId, children);
|
||||
});
|
||||
|
||||
const connectors = [];
|
||||
|
||||
Reference in New Issue
Block a user