Files
jiapuapp/pages/tree/add-relative.vue
T

1176 lines
38 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.
<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
:range="sexOptions.map((item) => item.label)"
:value="optionIndex(sexOptions, addForm.sex)"
@change="selectOption('sex', sexOptions, $event)"
>
<view class="form-field form-field--picker">
<text>性别</text
><text>{{ optionLabel(sexOptions, addForm.sex) || "请选择" }}</text>
</view>
</picker>
<picker
:range="bindingModeOptions.map((item) => item.label)"
:value="optionIndex(bindingModeOptions, addForm.bindingMode)"
@change="selectBindingMode"
>
<view class="form-field form-field--picker">
<text>身份认领</text
><text>{{
optionLabel(bindingModeOptions, addForm.bindingMode)
}}</text>
</view>
</picker>
<text v-if="fieldErrors.bindingMode" class="field-error">{{
fieldErrors.bindingMode
}}</text>
<picker
v-if="addForm.bindingMode === 'SPECIFIED'"
:range="memberOptionLabels"
:value="memberOptionIndex(addForm.appUserId)"
:disabled="memberOptionsState !== 'ready' || !memberOptions.length"
@change="selectBoundMember"
>
<view class="form-field form-field--picker">
<text><text class="required-mark">*</text>指定成员</text>
<text>{{ memberOptionLabel(addForm.appUserId) }}</text>
</view>
</picker>
<text
v-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'loading'"
class="form-note"
>正在读取可绑定成员</text
>
<text
v-else-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'error'"
class="field-error"
>可绑定成员暂不可用请稍后重试</text
>
<view class="form-field form-field--upload">
<text>头像</text>
<view>
<button
class="upload-button"
:disabled="isAvatarUploading || isSubmitting"
@click="uploadAvatar"
>
{{ isAvatarUploading ? "上传中…" : "选择图片" }}
</button>
<text v-if="avatarFileName" class="upload-receipt"
>已上传{{ avatarFileName }}</text
>
</view>
</view>
<text v-if="avatarUploadError" class="field-error">{{
avatarUploadError
}}</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.courtesyName"
maxlength="40"
placeholder="如族谱有记载可填写"
placeholder-class="form-placeholder"
/>
</view>
<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>
<view class="form-field">
<text>世代</text>
<input
v-if="!isFirstMember"
v-model="addForm.generation"
type="number"
placeholder="可选正整数"
placeholder-class="form-placeholder"
/>
<text v-else>首位成员固定为 1</text>
</view>
<picker
mode="date"
:value="addForm.birthDate"
@change="selectBirthDate"
>
<view class="form-field form-field--picker">
<text>出生日期</text
><text>{{ addForm.birthDate || "请选择" }}</text>
</view>
</picker>
<picker
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, addForm.birthLunar)"
@change="selectOption('birthLunar', lunarOptions, $event)"
>
<view class="form-field form-field--picker">
<text>出生农历</text
><text>{{
optionLabel(lunarOptions, addForm.birthLunar) || "请选择"
}}</text>
</view>
</picker>
<view class="form-field">
<text>出生地</text>
<input
v-model="addForm.birthPlace"
maxlength="60"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<picker
:range="zodiacOptions.map((item) => item.label)"
:value="optionIndex(zodiacOptions, addForm.zodiac)"
@change="selectOption('zodiac', zodiacOptions, $event)"
>
<view class="form-field form-field--picker">
<text>生肖</text><text>{{ optionLabel(zodiacOptions, addForm.zodiac) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>现居地</text>
<input
v-model="addForm.currentAddress"
maxlength="120"
placeholder="填写当前常住地区"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>联系电话</text>
<input
v-model="addForm.mobile"
maxlength="30"
placeholder="仅授权成员可见"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field">
<text>电子邮箱</text>
<input
v-model="addForm.email"
maxlength="254"
placeholder="仅授权成员可见"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.email" class="field-error">{{
fieldErrors.email
}}</text>
<picker
:range="educationOptions.map((item) => item.label)"
:value="optionIndex(educationOptions, addForm.education)"
@change="selectOption('education', educationOptions, $event)"
>
<view class="form-field form-field--picker">
<text>学历分类</text><text>{{ optionLabel(educationOptions, addForm.education) || "请选择" }}</text>
</view>
</picker>
<view class="form-field">
<text>职业</text>
<input
v-model="addForm.occupation"
maxlength="80"
placeholder="填写主要职业"
placeholder-class="form-placeholder"
/>
</view>
<picker
v-if="isDeceased"
mode="date"
:value="addForm.deathDate"
@change="selectDeathDate"
>
<view class="form-field form-field--picker">
<text>逝世日期</text
><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
</picker>
<picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, addForm.deathLunar)"
@change="selectOption('deathLunar', lunarOptions, $event)"
>
<view class="form-field form-field--picker">
<text>逝世农历</text
><text>{{
optionLabel(lunarOptions, addForm.deathLunar) || "请选择"
}}</text>
</view>
</picker>
<view v-if="isDeceased" class="form-field">
<text>享年</text>
<input
v-model="addForm.deathAge"
type="number"
placeholder="0 至 200 的整数"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.deathAge" class="field-error">{{
fieldErrors.deathAge
}}</text>
<view v-if="isDeceased" class="form-field">
<text>逝世地</text>
<input
v-model="addForm.deathPlace"
maxlength="60"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<picker
v-if="isDeceased"
:range="deathExpressionOptions.map((item) => item.label)"
:value="optionIndex(deathExpressionOptions, addForm.deathType)"
@change="selectOption('deathType', deathExpressionOptions, $event)"
>
<view class="form-field form-field--picker">
<text>逝世表述</text><text>{{ optionLabel(deathExpressionOptions, addForm.deathType) || "请选择" }}</text>
</view>
</picker>
<view v-if="isDeceased" class="form-field form-field--summary">
<text>遗传病史</text>
<textarea
v-model="addForm.hereditaryMedicalHistory"
auto-height
maxlength="500"
placeholder="敏感信息,无明确依据可不填写"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="isDeceased" class="form-note"
>遗传病史属于敏感健康信息后端必须按权限返回并记录访问审计</text
>
<picker
v-if="relationVariantOptions.length"
:range="relationVariantOptions.map((item) => item.label)"
:value="optionIndex(relationVariantOptions, addForm.relationVariantCode)"
@change="selectOption('relationVariantCode', relationVariantOptions, $event)"
>
<view class="form-field form-field--picker">
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
</view>
</picker>
<picker
v-if="isDeceased"
mode="date"
:value="addForm.burialDate"
@change="selectBurialDate"
>
<view class="form-field form-field--picker">
<text>安葬日期</text
><text>{{ addForm.burialDate || "请选择" }}</text>
</view>
</picker>
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
v-model="addForm.burialPlace"
maxlength="60"
placeholder="按家谱记载填写"
placeholder-class="form-placeholder"
/>
</view>
<text v-if="fieldErrors.dates" class="field-error">{{
fieldErrors.dates
}}</text>
<picker
:range="personStatusOptions.map((item) => item.label)"
:value="optionIndex(personStatusOptions, addForm.personStatus)"
@change="selectOption('personStatus', personStatusOptions, $event)"
>
<view class="form-field form-field--picker">
<text>人物状态</text
><text>{{
optionLabel(personStatusOptions, addForm.personStatus) || "请选择"
}}</text>
</view>
</picker>
<view class="form-field">
<text>排序值</text>
<input
v-model="addForm.sortOrder"
type="number"
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/scroll-primary.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/scroll-primary.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 {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
import {
findMemberOptionIndex,
findMemberOptionLabel,
memberFormOptions,
memberRelationOptions,
memberRelationTypes,
updateMemberBindingMode,
updateMemberFormOption,
} from "@/utils/tree/member-form.js";
const addState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const mode = ref("relative");
const relationType = ref("");
const isSubmitting = ref(false);
const memberCreationCommitted = ref(false);
const memberCreationOutcomeUnknown = ref(false);
const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false);
const currentMember = ref(null);
const errorMessage = ref("");
const memberDetailRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberCreationRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
const dictionaryRequestControllers = {
zodiac: createRequestController(),
education: createRequestController(),
deathExpression: createRequestController(),
parentVariant: createRequestController(),
spouseVariant: createRequestController(),
};
const memberCreationGuard = createNonIdempotentWriteGuard();
let pageActive = true;
let loadSequence = 0;
const relationIntents = Object.freeze(
Object.fromEntries(
memberRelationOptions.map(({ value, label }) => [value, Object.freeze({ label })]),
),
);
const genericRelationTypes = Object.freeze(
memberRelationOptions.map(({ value }) => value),
);
const sexOptions = memberFormOptions.sex;
const lunarOptions = memberFormOptions.lunar;
const personStatusOptions = memberFormOptions.personStatus;
const bindingModeOptions = memberFormOptions.bindingMode;
const addForm = reactive({
bindingMode: "NONE",
appUserId: "",
name: "",
relation: "",
courtesyName: "",
aliasName: "",
sex: "",
generation: "",
generationName: "",
avatarOssId: "",
birthDate: "",
birthLunar: "",
birthPlace: "",
zodiac: "",
currentAddress: "",
mobile: "",
email: "",
education: "",
occupation: "",
deathDate: "",
deathLunar: "",
deathAge: "",
deathPlace: "",
deathType: "",
hereditaryMedicalHistory: "",
relationVariantCode: "",
burialDate: "",
burialPlace: "",
personStatus: "",
biography: "",
remark: "",
sortOrder: "",
});
const fieldErrors = reactive({
name: "",
relation: "",
bindingMode: "",
email: "",
dates: "",
deathAge: "",
});
const memberOptionsState = ref("loading");
const memberOptions = ref([]);
const zodiacOptions = ref([]);
const educationOptions = ref([]);
const deathExpressionOptions = ref([]);
const parentRelationVariantOptions = ref([]);
const spouseRelationVariantOptions = ref([]);
const isFirstMember = computed(() => mode.value === "first");
const isDeceased = computed(() => addForm.personStatus === "1");
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 selectedRelationType = computed(() =>
isFirstMember.value
? ""
: activeRelationIntent.value
? relationType.value
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)] || "",
);
const relationVariantOptions = computed(() => {
if ([memberRelationTypes.FATHER, memberRelationTypes.MOTHER].includes(selectedRelationType.value)) {
return parentRelationVariantOptions.value;
}
return selectedRelationType.value === memberRelationTypes.SPOUSE
? spouseRelationVariantOptions.value
: [];
});
const memberOptionLabels = computed(() =>
memberOptions.value.map((item) => item.label),
);
const hasValidContext = computed(
() =>
/^[1-9]\d*$/.test(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 (memberCreationOutcomeUnknown.value) {
return {
eyebrow: "结果待确认",
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}可能已经保存`,
copy:
errorMessage.value ||
"暂时无法确认成员是否已经写入。请先返回世系树检查,不要重复新增。",
action: "返回世系树",
};
}
if (memberCreationCommitted.value) {
return {
eyebrow: "保存完成",
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}已经保存`,
copy:
errorMessage.value ||
"成员已经写入家谱,但页面暂时没有返回世系树。请重试返回,不要重复新增。",
action: "返回世系树",
};
}
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.entries(addForm).some(
([field, value]) =>
field !== "bindingMode" && field !== "appUserId" && 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 lineageApi.getPerson(genealogyId.value, personId.value, {
requestController: memberDetailRequestController,
});
if (!pageActive || activeLoad !== loadSequence) return;
currentMember.value = member;
addState.value = "form";
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
currentMember.value = null;
addState.value = "error";
errorMessage.value = getRequestErrorMessage(error, "当前成员暂不可用。");
}
};
const loadMemberOptions = async () => {
memberOptionsState.value = "loading";
try {
const rows = await genealogyMemberApi.getMemberOptions(genealogyId.value, {
requestController: memberOptionsRequestController,
});
if (!pageActive) return;
memberOptions.value = rows
.filter((item) => item.eligible)
.map((item) => ({
value: item.appUserId,
label: item.relationName
? `${item.memberName} · ${item.relationName}`
: item.memberName,
}));
memberOptionsState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
memberOptions.value = [];
memberOptionsState.value = "error";
}
};
const loadBusinessOptions = async () => {
try {
const [zodiacRows, educationRows, deathRows, parentRows, spouseRows] = await Promise.all([
businessDictionaryApi.getBusinessDictionaryOptions("gen_zodiac", { requestController: dictionaryRequestControllers.zodiac }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_education_type", { requestController: dictionaryRequestControllers.education }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_death_expression", { requestController: dictionaryRequestControllers.deathExpression }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_parent_relationship_variant", { requestController: dictionaryRequestControllers.parentVariant }),
businessDictionaryApi.getBusinessDictionaryOptions("gen_spouse_relationship_variant", { requestController: dictionaryRequestControllers.spouseVariant }),
]);
if (!pageActive) return;
zodiacOptions.value = zodiacRows;
educationOptions.value = educationRows;
deathExpressionOptions.value = deathRows;
parentRelationVariantOptions.value = parentRows;
spouseRelationVariantOptions.value = spouseRows;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
zodiacOptions.value = [];
educationOptions.value = [];
deathExpressionOptions.value = [];
parentRelationVariantOptions.value = [];
spouseRelationVariantOptions.value = [];
}
};
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 (
!/^[1-9]\d*$/.test(genealogyId.value) ||
(!isFirstMember.value && !/^[1-9]\d*$/.test(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;
}
void loadMemberOptions();
void loadBusinessOptions();
if (isFirstMember.value) {
addState.value = "form";
return;
}
void loadCurrentMember();
});
onUnload(() => {
pageActive = false;
loadSequence += 1;
memberDetailRequestController.abort();
memberOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberCreationRequestController.abort();
sensitiveProfileRequestController.abort();
Object.values(dictionaryRequestControllers).forEach((controller) => controller.abort());
discardConfirmation.dispose();
});
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
dirty: addState.value === "form" && hasDraft.value,
submitting: isSubmitting.value || isAvatarUploading.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)] || "";
addForm.relationVariantCode = "";
clearError("relation");
};
const optionIndex = findMemberOptionIndex;
const optionLabel = findMemberOptionLabel;
const selectOption = (field, options, event) => {
updateMemberFormOption(addForm, field, options, event);
};
const selectBindingMode = (event) => {
updateMemberBindingMode(addForm, event);
fieldErrors.bindingMode = "";
};
const memberOptionIndex = (value) =>
Math.max(
0,
memberOptions.value.findIndex((item) => item.value === value),
);
const memberOptionLabel = (value) =>
memberOptions.value.find((item) => item.value === value)?.label ||
(memberOptionsState.value === "loading"
? "正在读取"
: memberOptions.value.length
? "请选择可绑定成员"
: "暂无可绑定成员");
const selectBoundMember = (event) => {
addForm.appUserId =
memberOptions.value[Number(event.detail.value)]?.value || "";
fieldErrors.bindingMode = "";
};
const uploadAvatar = async () => {
if (isAvatarUploading.value || isSubmitting.value) return;
isAvatarUploading.value = true;
avatarUploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: avatarUploadRequestController,
});
if (!pageActive) return;
addForm.avatarOssId = receipt.ossId;
avatarFileName.value = receipt.fileName || "头像图片";
} catch (error) {
if (!pageActive) return;
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
avatarUploadError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
} finally {
if (pageActive) isAvatarUploading.value = false;
}
};
const selectBirthDate = (event) => {
addForm.birthDate = event.detail.value || "";
};
const selectDeathDate = (event) => {
addForm.deathDate = event.detail.value || "";
};
const selectBurialDate = (event) => {
addForm.burialDate = event.detail.value || "";
};
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
fieldErrors.relation =
isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
fieldErrors.bindingMode =
addForm.bindingMode === "SPECIFIED" && !addForm.appUserId
? memberOptionsState.value === "error"
? "可绑定成员暂不可用,请稍后重试"
: "请选择可绑定成员"
: "";
fieldErrors.email =
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
? "请填写有效的电子邮箱"
: "";
fieldErrors.dates =
addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
? "逝世日期不能早于出生日期"
: addForm.deathDate && addForm.burialDate && addForm.burialDate < addForm.deathDate
? "安葬日期不能早于逝世日期"
: "";
const deathAge = addForm.deathAge === "" ? null : Number(addForm.deathAge);
fieldErrors.deathAge =
deathAge !== null && (!Number.isSafeInteger(deathAge) || deathAge < 0 || deathAge > 200)
? "享年必须是 0 至 200 的整数"
: "";
return !Object.values(fieldErrors).some(Boolean);
};
const submitAdd = async () => {
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm())
return;
const payload = {
bindingMode: addForm.bindingMode,
...(addForm.bindingMode === "SPECIFIED"
? { appUserId: addForm.appUserId }
: {}),
name: addForm.name,
courtesyName: addForm.courtesyName,
aliasName: addForm.aliasName,
sex: addForm.sex,
generationName: addForm.generationName,
avatarOssId: addForm.avatarOssId,
birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar,
birthPlace: addForm.birthPlace,
zodiacCode: addForm.zodiac,
currentAddress: addForm.currentAddress,
mobile: addForm.mobile,
email: addForm.email,
educationCode: addForm.education,
occupation: addForm.occupation,
...(isDeceased.value
? {
deathDate: addForm.deathDate,
deathLunar: addForm.deathLunar,
deathAge: addForm.deathAge,
deathPlace: addForm.deathPlace,
deathExpressionCode: addForm.deathType,
burialDate: addForm.burialDate,
burialPlace: addForm.burialPlace,
}
: {}),
personStatus: addForm.personStatus,
biography: addForm.biography,
remark: addForm.remark,
sortOrder: addForm.sortOrder,
...(isFirstMember.value
? { generation: 1 }
: {
...(addForm.generation
? { generation: Number(addForm.generation) }
: {}),
...(relationType.value === memberRelationTypes.SPOUSE
? { relationName: relationLabel.value }
: {}),
...(addForm.relationVariantCode
? { relationVariantCode: addForm.relationVariantCode }
: {}),
}),
};
const submittedRelationType = isFirstMember.value
? ""
: activeRelationIntent.value
? relationType.value
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
const createAttempt = memberCreationGuard.begin({
genealogyId: genealogyId.value,
personId: isFirstMember.value ? "" : personId.value,
relationType: submittedRelationType,
payload,
});
if (createAttempt === null) {
memberCreationOutcomeUnknown.value = true;
errorMessage.value =
"上次保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
addState.value = "error";
return;
}
isSubmitting.value = true;
try {
let createdPerson;
if (isFirstMember.value) {
createdPerson = await lineageApi.createPerson(genealogyId.value, payload, {
requestController: memberCreationRequestController,
});
} else {
createdPerson = await lineageApi.createRelatedPerson(
genealogyId.value,
personId.value,
submittedRelationType,
payload,
{ requestController: memberCreationRequestController },
);
}
if (!pageActive) return;
memberCreationCommitted.value = true;
const sensitiveHistory = addForm.hereditaryMedicalHistory.trim();
if (sensitiveHistory) {
const createdPersonId = String(createdPerson?.personId || "");
if (!/^[1-9]\d*$/.test(createdPersonId)) {
throw new Error("成员已经保存,但响应缺少人物标识,敏感健康资料尚未保存。请返回成员档案补充。");
}
await lineageApi.saveSensitiveProfile(
genealogyId.value,
createdPersonId,
sensitiveHistory,
{ requestController: sensitiveProfileRequestController },
);
}
await returnTo("T01", { genealogyId: genealogyId.value });
} catch (error) {
if (!pageActive) return;
if (
!memberCreationCommitted.value &&
memberCreationGuard.recordFailure(createAttempt, error)
) {
memberCreationOutcomeUnknown.value = true;
errorMessage.value =
"保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
addState.value = "error";
return;
}
if (isRequestCancelled(error)) return;
errorMessage.value = memberCreationCommitted.value
? "成员已经保存,但页面返回失败。请重试返回世系树,不要重复新增。"
: getRequestErrorMessage(error, "成员资料尚未保存,请稍后重试。");
addState.value = "error";
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const handleResultAction = () =>
memberCreationCommitted.value || memberCreationOutcomeUnknown.value
? returnTo("T01", { genealogyId: genealogyId.value })
: 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">
@import "../../styles/adaptive-frame-profiles.scss";
.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-tree-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
padding: 7.5% 8%;
}
.form-eyebrow {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
letter-spacing: 3rpx;
}
.form-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
line-height: 1.35;
}
.form-copy,
.form-note {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.5;
}
.member-context,
.form-field {
@include 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: clamp(15px, 24rpx, 18px);
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: clamp(15px, 24rpx, 18px);
line-height: 1.45;
text-align: right;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1;
transform: translateY(-2rpx);
}
.form-field textarea {
width: auto;
min-height: var(--app-touch-min);
text-align: left;
}
.form-field input {
min-height: var(--app-touch-min);
}
.form-field__hint,
.upload-receipt {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.4;
text-align: right;
}
.form-field--upload > view {
display: grid;
justify-items: end;
gap: 6rpx;
}
.upload-button {
min-height: var(--app-touch-min);
margin: 0;
padding: 0 16rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.form-field--summary {
align-items: start;
min-height: 200rpx;
}
.form-placeholder {
color: #a79884;
}
.field-error {
display: block;
margin: 5rpx 18rpx 0;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: max(1.35em, clamp(17px, 32rpx, 22px));
}
.form-note {
text-align: center;
}
.form-action {
display: grid;
width: 100%;
min-height: var(--app-touch-min);
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: clamp(15px, 24rpx, 18px);
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>