326 lines
13 KiB
Vue
326 lines
13 KiB
Vue
<!-- 页面编号: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>
|
||
<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 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">{{ 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: "", birthDate: "", summary: "" });
|
||
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: `${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 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,
|
||
birthDate: addForm.birthDate,
|
||
biography: addForm.summary,
|
||
...(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: "", birthDate: "", summary: "" });
|
||
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; }
|
||
.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>
|