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

411 lines
16 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--loading': addState === 'loading',
'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">
<AppLoading v-if="addState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认当前人物。" />
<view v-else-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 class="required-mark">*</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 class="required-mark">*</text>与本人关系</text>
<text>{{ addForm.relation || "请选择亲属关系" }}</text>
</view>
</picker>
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
<view class="form-field">
<text>别名</text>
<input v-model="addForm.aliasName" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>字辈</text>
<input v-model="addForm.generationName" maxlength="12" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<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">
<text>出生农历</text>
<input v-model="addForm.birthLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>出生地</text>
<input v-model="addForm.birthPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<picker mode="date" :value="addForm.deathDate" @change="selectDeathDate">
<view class="form-field form-field--picker">
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>逝世农历</text>
<input v-model="addForm.deathLunar" maxlength="40" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>逝世地</text>
<input v-model="addForm.deathPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field">
<text>安葬地</text>
<input v-model="addForm.burialPlace" maxlength="60" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
</view>
<view class="form-field form-field--summary">
<text>人物简介</text>
<textarea
v-model="addForm.biography"
auto-height
maxlength="500"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field form-field--summary">
<text>备注</text>
<textarea
v-model="addForm.remark"
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">{{ resultCopy.eyebrow }}</text>
<text class="form-title">{{ resultCopy.title }}</text>
<text class="form-copy">{{ resultCopy.copy }}</text>
<view class="form-action" @click="handleResultAction">
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
<text>{{ resultCopy.action }}</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 AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const addState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const mode = ref("relative");
const relationType = ref("");
const isSubmitting = ref(false);
const discardDialogVisible = ref(false);
const currentMember = ref(null);
const errorMessage = ref("");
const addRequestController = createRequestController();
let loadSequence = 0;
const relationIntents = Object.freeze({
FATHER: { label: "父亲" },
MOTHER: { label: "母亲" },
SPOUSE: { label: "配偶" },
SIBLING: { label: "兄弟姐妹" },
SON: { label: "儿子" },
DAUGHTER: { label: "女儿" },
});
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
const addForm = reactive({
name: "",
relation: "",
aliasName: "",
generationName: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
biography: "",
remark: "",
});
const fieldErrors = reactive({ name: "", relation: "" });
const isFirstMember = computed(() => mode.value === "first");
const activeRelationIntent = computed(() => relationIntents[relationType.value] || null);
const relationOptions = computed(() => {
const types = activeRelationIntent.value ? [relationType.value] : genericRelationTypes;
return types.map((type) => relationIntents[type].label);
});
const relationLabel = computed(() => activeRelationIntent.value?.label || addForm.relation || "亲属关系");
const hasValidContext = computed(
() =>
Boolean(genealogyId.value) &&
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
);
const relationIndex = computed(() => Math.max(0, relationOptions.value.indexOf(addForm.relation)));
const formTitle = computed(() =>
isFirstMember.value ? "录入家谱中的第一位成员" : `为${currentMember.value?.name || "当前成员"}添加${relationLabel.value}`,
);
const formCopy = computed(() =>
isFirstMember.value
? "首位成员将成为世系起点,后续可从此人继续补充配偶、子女和后代。"
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() =>
"红色 * 为必填项,其余内容可按家谱记载补充。",
);
const resultCopy = computed(() => {
if (hasValidContext.value) {
return {
eyebrow: "保存失败",
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}尚未保存`,
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
action: "返回修改",
};
}
return {
eyebrow: "成员入口无效",
title: "没有找到要关联的成员",
copy: errorMessage.value || "请从世系树重新进入。",
action: "返回世系树",
};
});
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;
const loadCurrentMember = async () => {
const activeLoad = ++loadSequence;
addState.value = "loading";
try {
const member = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: addRequestController,
});
if (activeLoad !== loadSequence) return;
currentMember.value = member;
addState.value = "form";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
currentMember.value = null;
addState.value = "error";
errorMessage.value = error?.message || "当前成员暂不可用。";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
mode.value = query.mode === "first" ? "first" : "relative";
relationType.value = String(query.relationType || "");
if (!genealogyId.value || query.state === "error" || (!isFirstMember.value && !personId.value)) {
addState.value = "error";
errorMessage.value = "当前家谱或成员上下文无效,请从世系树重新进入。";
return;
}
if (relationType.value && !activeRelationIntent.value) {
addState.value = "error";
errorMessage.value = "未知的亲属关系意图,本页不会推断或替换。";
return;
}
if (activeRelationIntent.value) {
addForm.relation = activeRelationIntent.value.label;
}
if (isFirstMember.value) {
addState.value = "form";
return;
}
void loadCurrentMember();
});
onUnload(() => {
loadSequence += 1;
addRequestController.abort();
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: addState.value === "form" && 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.value[Number(event.detail.value)] || "";
clearError("relation");
};
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
const selectDeathDate = (event) => { addForm.deathDate = event.detail.value || ""; };
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
return !fieldErrors.name && !fieldErrors.relation;
};
const submitAdd = async () => {
if (isSubmitting.value || !validateAddForm()) return;
isSubmitting.value = true;
try {
const payload = {
name: addForm.name,
aliasName: addForm.aliasName,
generationName: addForm.generationName,
birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar,
birthPlace: addForm.birthPlace,
deathDate: addForm.deathDate,
deathLunar: addForm.deathLunar,
deathPlace: addForm.deathPlace,
burialPlace: addForm.burialPlace,
biography: addForm.biography,
remark: addForm.remark,
...(isFirstMember.value ? { generation: 1 } : {}),
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
};
if (isFirstMember.value) {
await appApi.createPerson(genealogyId.value, payload, { requestController: addRequestController });
} else {
const type = activeRelationIntent.value
? relationType.value
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
}
Object.assign(addForm, {
name: "",
relation: "",
aliasName: "",
generationName: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
biography: "",
remark: "",
});
await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) {
if (isRequestCancelled(error)) return;
errorMessage.value = error?.message || "服务未确认本次写入。";
addState.value = "error";
} finally {
isSubmitting.value = false;
}
};
const handleResultAction = () => hasValidContext.value ? (addState.value = "form") : returnToTree();
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; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 22rpx; line-height: 1; transform: translateY(-2rpx); }
.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>