Files
jiapuapp/pages/tree/t04-add-relative.vue
T
2026-07-23 08:24:07 +08:00

273 lines
12 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-04用途录入首位成员或为指定成员新增亲属 -->
<template>
<view
class="add-relative-page"
:class="{
'add-state--form': addState === 'form',
'add-state--preview': addState === 'preview',
'add-state--error': addState === 'error',
}"
>
<ModulePageBackground module="tree" />
<view class="add-relative-page__header">
<PageHeader :title="isFirstMember ? '录入首位成员' : '新增亲属'" custom-back @back="requestBack" />
</view>
<view class="add-relative-panel">
<view v-if="addState === 'form'" class="add-relative-form">
<text class="form-eyebrow">{{ isFirstMember ? "建立世系起点" : "补全家族关系" }}</text>
<text class="form-title">{{ formTitle }}</text>
<text class="form-copy">{{ formCopy }}</text>
<view v-if="!isFirstMember" class="member-context">
<text>当前成员</text><text>{{ currentMember.name }} · {{ currentMember.generation }} </text>
</view>
<view class="form-field">
<text>姓名</text>
<input
v-model="addForm.name"
maxlength="20"
placeholder="请输入真实姓名"
placeholder-class="form-placeholder"
@input="clearError('name')"
/>
</view>
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
<picker
v-if="!isFirstMember"
:range="relationOptions"
:value="relationIndex"
@change="selectRelation"
>
<view class="form-field form-field--picker">
<text>与本人关系</text>
<text>{{ addForm.relation || "请选择亲属关系" }}</text>
</view>
</picker>
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
<picker :range="genderOptions" :value="genderIndex" @change="selectGender">
<view class="form-field form-field--picker">
<text>性别</text><text>{{ addForm.gender || "请选择" }}</text>
</view>
</picker>
<text v-if="fieldErrors.gender" class="field-error">{{ fieldErrors.gender }}</text>
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
<view class="form-field form-field--picker">
<text>出生日期</text><text>{{ addForm.birthDate || "选填" }}</text>
</view>
</picker>
<view class="form-field form-field--summary">
<text>简要说明</text>
<textarea
v-model="addForm.summary"
auto-height
maxlength="200"
placeholder="选填:字辈、祖居地或身份说明"
placeholder-class="form-placeholder"
/>
</view>
<text class="form-note">{{ formNote }}</text>
<view class="form-action" @click="submitAdd">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
</view>
</view>
<view v-else class="add-result">
<text class="form-eyebrow">{{ addState === "preview" ? "本地流程预览" : hasValidContext ? "成员未保存" : "成员入口无效" }}</text>
<text class="form-title">{{ addState === "preview" ? previewTitle : hasValidContext ? "暂时无法校验成员" : "没有找到要关联的成员" }}</text>
<text class="form-copy">{{ addState === "preview" ? previewCopy : hasValidContext ? "当前填写内容仍保留,可返回修改后重试。" : "请从世系树重新进入,页面不会创建临时成员身份。" }}</text>
<view class="form-action" @click="addState === 'preview' ? returnToTree() : retryForm()">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ addState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "返回修改" : "返回上一页" }}</text>
</view>
</view>
</view>
<AppDialog
:visible="discardDialogVisible"
eyebrow="尚未保存"
title="要放弃本次填写吗"
message="返回后,本次新增成员的内容不会保留。"
cancel-text="继续填写"
confirm-text="放弃并返回"
show-cancel
:close-on-mask="false"
@cancel="cancelDiscard"
@confirm="confirmDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { findTreeMemberFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const addState = ref("form");
const genealogyId = ref("");
const personId = ref("");
const mode = ref("relative");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
let submitTimer = null;
const relationOptions = ["长子", "次子", "女儿", "配偶", "兄弟", "姐妹"];
const genderOptions = ["男", "女", "未说明"];
const addForm = reactive({ name: "", relation: "", gender: "", birthDate: "", summary: "" });
const fieldErrors = reactive({ name: "", relation: "", gender: "" });
const isFirstMember = computed(() => mode.value === "first");
const currentMember = computed(() =>
isFirstMember.value
? null
: findTreeMemberFixture(genealogyId.value, personId.value),
);
const hasValidContext = computed(
() =>
Boolean(genealogyId.value) &&
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
);
const relationIndex = computed(() => Math.max(0, relationOptions.indexOf(addForm.relation)));
const genderIndex = computed(() => Math.max(0, genderOptions.indexOf(addForm.gender)));
const formTitle = computed(() =>
isFirstMember.value ? "录入家谱中的第一位成员" : `为${currentMember.value.name}添加一位亲属`,
);
const formCopy = computed(() =>
isFirstMember.value
? "首位成员将成为世系起点,后续可从此人继续补充配偶、子女和后代。"
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() =>
isFirstMember.value
? "当前阶段只校验首位成员资料,不会修改世系树。"
: "当前阶段只校验亲属资料,不会新增成员或改变世系关系。",
);
const previewTitle = computed(() =>
isFirstMember.value ? `${addForm.name}可作为世系起点` : `${addForm.name}的亲属资料已通过本地校验`,
);
const previewCopy = computed(() =>
`本地预览尚未提交服务器,返回后不会保存${isFirstMember.value ? "首位成员" : `${addForm.relation}关系`},世系树也不会变化。`,
);
const hasDraft = computed(() =>
Object.values(addForm).some((value) => String(value).trim()),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
mode.value = query.mode === "first" ? "first" : "relative";
addState.value = !hasValidContext.value || query.state === "error"
? "error"
: query.state === "preview"
? "preview"
: "form";
});
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: (addState.value === "form" || addState.value === "preview") && hasDraft.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const clearError = (field) => { fieldErrors[field] = ""; };
const selectRelation = (event) => {
addForm.relation = relationOptions[Number(event.detail.value)] || "";
clearError("relation");
};
const selectGender = (event) => {
addForm.gender = genderOptions[Number(event.detail.value)] || "";
clearError("gender");
};
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
fieldErrors.gender = addForm.gender ? "" : "请选择性别或未说明";
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.gender;
};
const submitAdd = () => {
if (isSubmitting.value || !validateAddForm()) return;
const submitSnapshot = Object.freeze({ ...addForm });
isSubmitting.value = true;
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
addState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
isSubmitting.value = false;
submitTimer = null;
}, 280);
submitTimer = timer;
};
const retryForm = () => {
if (!hasValidContext.value) return goBack();
addState.value = "form";
};
const returnToTree = async () => {
const confirmed = hasDraft.value
? await requestDiscardConfirmation()
: true;
if (!confirmed) return false;
return returnTo("T01", { genealogyId: genealogyId.value });
};
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.add-relative-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.add-relative-page__header { z-index: 3; }
.add-relative-panel { @include adaptive.adaptive-tree-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 28rpx; padding: 7.5% 8%; }
.form-eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
.form-title { display: block; margin-top: 10rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; line-height: 1.35; }
.form-copy, .form-note { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.member-context, .form-field { @include adaptive.adaptive-tree-field; display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 14rpx; padding: 12rpx 22rpx; }
.member-context text:first-child, .form-field > text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
.member-context text:last-child, .form-field > text:last-child, .form-field input, .form-field textarea { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
.form-field textarea { width: auto; min-height: 54rpx; text-align: left; }
.form-field--summary { align-items: start; }
.form-placeholder { color: #a79884; }
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
.form-note { text-align: center; }
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 18rpx; }
.form-action image, .form-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
.form-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.add-result { margin-top: 30%; text-align: center; }
.add-result .form-eyebrow, .add-result .form-copy { text-align: center; }
.add-result .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
@media (min-width: 400px) { .add-relative-panel { width: calc(100% - 48rpx); } }
</style>