Files
jiapuapp/pages/genealogy/g03-create-genealogy.vue
T
2026-07-23 08:24:07 +08:00

736 lines
20 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.
<!-- 页面编号G-03用途创建家谱与录入首代人物的同路由两步流程 -->
<template>
<view class="flow-page">
<GenealogyPageBackground />
<PageHeader
:title="isAncestorStep ? '录入首代人物' : '创建家谱'"
custom-back
@back="requestBack"
/>
<view class="flow-content">
<view class="create-flow-panel">
<view v-if="!isAncestorStep" class="create-flow-panel__content">
<view class="flow-step-label"><text>第一步 · 立谱信息</text></view>
<text class="flow-heading">为家族立一部可传承的谱</text>
<text class="flow-note"
>家谱建立后可继续完善名称与访问规则由创建者维护</text
>
<view class="flow-fields">
<view class="field-row">
<text>姓氏</text>
<input
v-model="createForm.surname"
maxlength="4"
placeholder="请输入姓氏"
placeholder-class="placeholder"
@input="clearFieldError('surname')"
/>
</view>
<text v-if="fieldErrors.surname" class="field-error">{{
fieldErrors.surname
}}</text>
<view class="field-row">
<text>谱名</text>
<input
v-model="createForm.name"
maxlength="24"
placeholder="请输入家谱名称"
placeholder-class="placeholder"
@input="clearFieldError('name')"
/>
</view>
<text v-if="fieldErrors.name" class="field-error">{{
fieldErrors.name
}}</text>
<view class="field-row">
<text>堂号</text>
<input
v-model="createForm.hall"
maxlength="12"
placeholder="选填"
placeholder-class="placeholder"
/>
</view>
<view class="field-row">
<text>所在地</text>
<input
v-model="createForm.location"
maxlength="30"
placeholder="请选择或输入"
placeholder-class="placeholder"
@input="clearFieldError('location')"
/>
</view>
<text v-if="fieldErrors.location" class="field-error">{{
fieldErrors.location
}}</text>
</view>
<view class="flow-rule">
<text class="flow-rule__label">访问规则</text>
<view class="flow-rule__options">
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="flow-rule__option"
:class="{
'flow-rule__option--active':
createForm.accessPreset === option.value,
}"
@click="createForm.accessPreset = option.value"
>{{ option.label }}</view
>
</view>
</view>
<text class="flow-rule__note"
>保护族人资料创建后可在家谱设置中开放搜索与申请加入</text
>
<text v-if="createState === 'error'" class="flow-error"
>创建样式模拟失败请检查信息后重试</text
>
<view
class="flow-primary-action"
hover-class="action-hover"
@click="submitCreate"
>
<text class="flow-primary-action__copy">{{
createState === "submitting" ? "正在创建…" : "创建并录入首代"
}}</text>
</view>
</view>
<view v-else class="create-flow-panel__content">
<view class="flow-step-label"><text>第二步 · 首代人物</text></view>
<text class="flow-heading">家谱从这一位开始</text>
<text class="flow-note"
>可先录入姓名世代和简要生平其他资料后续随时补充</text
>
<view class="flow-fields">
<view class="field-row">
<text>姓名</text>
<input
v-model="ancestorForm.personName"
maxlength="20"
placeholder="请输入首代姓名"
placeholder-class="placeholder"
@input="clearFieldError('personName')"
/>
</view>
<text v-if="fieldErrors.personName" class="field-error">{{
fieldErrors.personName
}}</text>
<view class="field-row">
<text>世代</text>
<text class="fixed-value">第一世</text>
</view>
<picker
mode="selector"
:range="sexOptions"
range-key="label"
@change="changeAncestorSex"
>
<view class="field-row">
<text>性别</text>
<text class="fixed-value">{{ sexLabel }}</text>
</view>
</picker>
<view class="field-row">
<text>出生日期</text>
<picker
mode="date"
:value="ancestorForm.birthDate"
start="1800-01-01"
:end="new Date().toISOString().slice(0, 10)"
@change="changeAncestorBirthDate"
>
<text class="fixed-value">{{
ancestorForm.birthDate || "请选择日期"
}}</text>
</picker>
</view>
</view>
<text v-if="ancestorState === 'error'" class="flow-error"
>首代人物保存失败请稍后重试</text
>
<view class="intro-field">
<text>生平简述</text>
<textarea
v-model="ancestorForm.introduction"
auto-height
maxlength="200"
placeholder="可选,记录家训、迁徙或重要经历"
placeholder-class="placeholder"
/>
</view>
<view
class="flow-primary-action"
hover-class="action-hover"
@click="submitAncestor"
>
<text class="flow-primary-action__copy">{{
ancestorState === "submitting" ? "正在保存…" : "保存并进入家谱"
}}</text>
</view>
</view>
</view>
</view>
<view
v-if="duplicateReminderVisible"
class="duplicate-reminder-layer"
@click="closeDuplicateReminder"
>
<view class="duplicate-reminder" @click.stop>
<view class="duplicate-reminder__content">
<text class="duplicate-reminder__title">先确认是否已有家谱</text>
<text class="duplicate-reminder__copy"
>同一家族可能已经建谱建议先搜索谱名地区和堂号避免重复创建</text
>
<view
class="duplicate-reminder__search"
@click="searchExistingGenealogy"
>
<text>先搜索已有家谱</text>
</view>
<view class="duplicate-reminder__confirm" @click="confirmCreate">
<text>确认没有继续创建</text>
</view>
<view
class="duplicate-reminder__cancel"
@click="closeDuplicateReminder"
>返回修改</view
>
</view>
</view>
</view>
<view v-if="ancestorState === 'success'" class="flow-success-layer">
<view class="flow-success-dialog">
<view class="flow-success-dialog__content">
<text class="flow-success-dialog__title">家谱创建完成</text>
<text class="flow-success-dialog__copy"
>当前为本地流程预览资料尚未提交服务器可进入总览继续检查页面</text
>
<view class="flow-success-dialog__action" @click="enterOverview">
<text>进入家谱总览</text>
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃创建?"
message="当前填写内容尚未保存,确认返回后将清空。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
compact-actions
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createLocalGenealogyPreview,
removeLocalGenealogyPreview,
updateLocalGenealogyPreview,
updateLocalGenealogyPreviewAncestor,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
} from "@/utils/genealogy-contracts.js";
import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation.js";
const currentStep = ref("create");
const genealogyId = ref("");
const isSubmitting = ref(false);
const createState = ref("form");
const ancestorState = ref("form");
const duplicateReminderVisible = ref(false);
const discardVisible = ref(false);
const fieldErrors = reactive({
surname: "",
name: "",
location: "",
personName: "",
});
let submitTimer = null;
const createForm = reactive({
surname: "",
name: "",
hall: "",
location: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const ancestorForm = reactive({
personName: "",
generationNo: 1,
generationName: "一世",
sex: "0",
birthDate: "",
introduction: "",
});
const sexOptions = [
{ value: "0", label: "男" },
{ value: "1", label: "女" },
{ value: "2", label: "暂不填写" },
];
const sexLabel = computed(
() =>
sexOptions.find((option) => option.value === ancestorForm.sex)?.label ||
"请选择",
);
const changeAncestorSex = (event) => {
ancestorForm.sex = sexOptions[Number(event.detail.value)]?.value || "2";
};
const changeAncestorBirthDate = (event) => {
ancestorForm.birthDate = event.detail.value;
};
const isAncestorStep = computed(() => currentStep.value === "ancestor");
const isDirty = computed(() =>
isAncestorStep.value
? Boolean(
ancestorForm.personName ||
ancestorForm.birthDate ||
ancestorForm.introduction ||
ancestorForm.sex !== "0",
)
: Boolean(
createForm.surname ||
createForm.name ||
createForm.hall ||
createForm.location ||
createForm.accessPreset !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestRawDiscardConfirmation = discardConfirmation.request;
const requestDiscardConfirmation = async () => {
const confirmed = await requestRawDiscardConfirmation();
if (confirmed && currentStep.value === "create" && genealogyId.value) {
removeLocalGenealogyPreview(genealogyId.value);
genealogyId.value = "";
}
return confirmed;
};
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onUnload(() => {
const timer = submitTimer;
submitTimer = null;
if (timer) clearTimeout(timer);
discardConfirmation.dispose();
});
const closeActiveTransient = () => {
if (duplicateReminderVisible.value) closeDuplicateReminder();
else cancelDiscard();
};
const popInternalTrail = () => {
currentStep.value = "create";
ancestorState.value = "form";
return true;
};
const requestBack = () => {
if (ancestorState.value === "success") return enterOverview();
return runBackGuard({
transientOpen: duplicateReminderVisible.value || discardVisible.value,
internalTrail: isAncestorStep.value && !isSubmitting.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": closeActiveTransient,
"pop-internal-trail": popInternalTrail,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
const clearFieldError = (field) => {
fieldErrors[field] = "";
};
const validateCreateForm = () => {
fieldErrors.surname = createForm.surname.trim() ? "" : "请填写姓氏";
fieldErrors.name = createForm.name.trim() ? "" : "请填写谱名";
fieldErrors.location = createForm.location.trim() ? "" : "请填写所在地";
return !fieldErrors.surname && !fieldErrors.name && !fieldErrors.location;
};
const submitCreate = () => {
if (isSubmitting.value) return;
createState.value = "form";
if (!validateCreateForm()) return;
duplicateReminderVisible.value = true;
};
const closeDuplicateReminder = () => {
duplicateReminderVisible.value = false;
};
const searchExistingGenealogy = () => {
closeDuplicateReminder();
return openPage("G06", {}, "G03");
};
const confirmCreate = () => {
if (isSubmitting.value) return;
closeDuplicateReminder();
const createSnapshot = Object.freeze({ ...createForm });
isSubmitting.value = true;
createState.value = "submitting";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
if (createSnapshot.name.trim() === "失败") {
createState.value = "error";
return;
}
genealogyId.value =
updateLocalGenealogyPreview(genealogyId.value, createSnapshot) ||
createLocalGenealogyPreview(createSnapshot);
currentStep.value = "ancestor";
createState.value = "form";
}, 320);
submitTimer = timer;
};
const submitAncestor = () => {
if (isSubmitting.value) return;
if (!genealogyId.value) {
ancestorState.value = "error";
return;
}
if (!ancestorForm.personName.trim()) {
fieldErrors.personName = "请填写首代姓名";
return;
}
const ancestorSnapshot = Object.freeze({ ...ancestorForm });
isSubmitting.value = true;
ancestorState.value = "submitting";
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
isSubmitting.value = false;
if (ancestorSnapshot.personName.trim() === "失败") {
ancestorState.value = "error";
return;
}
ancestorState.value = updateLocalGenealogyPreviewAncestor(
genealogyId.value,
ancestorSnapshot,
)
? "success"
: "error";
}, 320);
submitTimer = timer;
};
const enterOverview = () =>
returnTo("G05", { genealogyId: genealogyId.value });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.flow-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: #f9f6ef;
}
.flow-content {
z-index: 2;
padding: 24rpx 32rpx 48rpx;
}
.create-flow-panel {
@include adaptive.adaptive-genealogy-state-panel;
min-height: 1000rpx;
}
.create-flow-panel__content {
z-index: 1;
display: flex;
min-height: 1000rpx;
flex-direction: column;
box-sizing: border-box;
padding: 48rpx 50rpx 42rpx;
}
.flow-step-label {
color: #a33b2b;
font-family: "STKaiti", "KaiTi", serif;
font-size: 25rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.flow-heading {
display: block;
margin-top: 13rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 39rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.flow-note,
.flow-rule__note {
display: block;
color: #786654;
font-size: 25rpx;
line-height: 1.65;
}
.flow-note {
min-height: 56rpx;
margin-top: 12rpx;
}
.flow-fields {
margin-top: 10rpx;
}
.field-row {
display: flex;
min-height: 86rpx;
align-items: center;
border-bottom: 1rpx solid rgba(181, 138, 75, 0.36);
}
.field-row > text {
width: 126rpx;
flex: 0 0 auto;
color: #786654;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
}
.field-row input {
min-width: 0;
flex: 1;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 28rpx;
}
.placeholder {
color: #b7aa97;
}
.field-error {
display: block;
margin-top: 3rpx;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
text-align: right;
}
.fixed-value {
color: #a33b2b !important;
}
.flow-rule {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20rpx;
}
.flow-rule__label {
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.flow-rule__options {
display: flex;
gap: 12rpx;
}
.flow-rule__option {
display: flex;
min-height: 54rpx;
align-items: center;
padding: 0 10rpx;
color: #786654;
font-size: 23rpx;
}
.flow-rule__option--active {
color: $brand-red;
font-weight: 700;
}
.flow-rule__note {
margin-top: 8rpx;
}
.flow-error {
display: block;
margin-top: 10rpx;
color: $brand-red;
font-size: 25rpx;
line-height: 36rpx;
text-align: center;
}
.intro-field {
margin-top: 18rpx;
}
.intro-field > text {
display: block;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 29rpx;
font-weight: 700;
}
.intro-field textarea {
width: 100%;
min-height: 142rpx;
margin-top: 8rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: 26rpx;
line-height: 1.6;
}
.flow-primary-action {
display: flex;
width: 100%;
min-height: 96rpx;
align-items: center;
justify-content: center;
margin-top: auto;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.flow-primary-action__copy {
z-index: 1;
color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif;
font-size: 31rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.action-hover {
opacity: 0.82;
}
.duplicate-reminder-layer,
.flow-success-layer {
position: fixed;
z-index: 30;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 42rpx;
background: rgba(34, 20, 12, 0.6);
}
.duplicate-reminder,
.flow-success-dialog {
width: 100%;
max-width: 670rpx;
min-height: 650rpx;
background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__content,
.flow-success-dialog__content {
z-index: 1;
display: flex;
min-height: 650rpx;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 92rpx 64rpx 56rpx;
text-align: center;
}
.duplicate-reminder__title,
.flow-success-dialog__title {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 39rpx;
font-weight: 700;
}
.duplicate-reminder__copy,
.flow-success-dialog__copy {
margin-top: 24rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 40rpx;
}
.duplicate-reminder__search,
.duplicate-reminder__confirm,
.flow-success-dialog__action {
display: flex;
width: 100%;
min-height: 92rpx;
align-items: center;
justify-content: center;
}
.duplicate-reminder__search {
margin-top: 38rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__confirm {
margin-top: 14rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
.duplicate-reminder__search text,
.duplicate-reminder__confirm text,
.flow-success-dialog__action text {
z-index: 1;
color: #fff9ec;
font-size: 25rpx;
font-weight: 700;
}
.duplicate-reminder__search text {
color: #7b4e24;
}
.duplicate-reminder__cancel {
display: flex;
min-height: 68rpx;
align-items: center;
margin-top: auto;
color: $ink-muted;
font-size: 23rpx;
}
.flow-success-dialog__action {
margin-top: 44rpx;
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
center / contain no-repeat;
}
</style>