Files
jiapuapp/pages/genealogy/create.vue
T

702 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.
<template>
<view class="create-page">
<GenealogyPageBackground />
<PageHeader title="创建家谱" custom-back @back="requestBack" />
<view class="page-content">
<view class="create-card">
<text class="create-card__eyebrow">立谱信息</text>
<text class="create-card__title">为家族创建一部家谱</text>
<text class="create-card__note"
>创建时会同步建立始迁祖人物创建成功后会回到我的家谱”。</text
>
<view class="field-row">
<text class="field-row__label"
><text class="required-mark">*</text>姓氏</text
>
<input
v-model="form.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 class="field-row__label"
><text class="required-mark">*</text>谱名</text
>
<input
v-model="form.genealogyName"
maxlength="24"
placeholder="请输入家谱名称"
placeholder-class="placeholder"
@input="clearFieldError('genealogyName')"
/>
</view>
<text v-if="fieldErrors.genealogyName" class="field-error">{{
fieldErrors.genealogyName
}}</text>
<view
class="field-row field-row--selector"
role="button"
aria-label="选择所在地区"
@click="openRegionPicker"
>
<text class="field-row__label"
><text class="required-mark">*</text>所在地区</text
>
<text
class="region-selector-value"
:class="{ 'region-selector-value--placeholder': !selectedRegion }"
>{{
selectedRegion
? regionPickerTrail.map((item) => item.label).join(" / ")
: regionLoading
? "正在加载地区…"
: regionPickerError || "请选择所在地区"
}}</text
>
<image
class="region-selector-chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
<text v-if="fieldErrors.regionCode" class="field-error">{{
fieldErrors.regionCode
}}</text>
<view class="field-row">
<text class="field-row__label">堂号</text>
<input
v-model="form.ancestralHall"
maxlength="12"
placeholder="请输入堂号"
placeholder-class="placeholder"
/>
</view>
<view class="field-row">
<text class="field-row__label"
><text class="required-mark">*</text>始迁祖</text
>
<input
v-model="form.firstAncestorName"
maxlength="30"
placeholder="请输入始迁祖姓名"
placeholder-class="placeholder"
@input="clearFieldError('firstAncestorName')"
/>
</view>
<text v-if="fieldErrors.firstAncestorName" class="field-error">{{
fieldErrors.firstAncestorName
}}</text>
<view class="owner-ancestor-field">
<view>
<text class="owner-ancestor-field__label">谱主本人就是始迁祖</text>
<text class="owner-ancestor-field__hint"
>仅本人确为始迁祖时开启开启后账号会绑定到该人物</text
>
</view>
<switch
:checked="form.ownerIsFirstAncestor"
color="#9f170f"
aria-label="谱主本人就是始迁祖"
@change="form.ownerIsFirstAncestor = $event.detail.value"
/>
</view>
<view class="field-row">
<text class="field-row__label">所在地</text>
<input
v-model="form.originPlace"
maxlength="30"
placeholder="请输入所在地"
placeholder-class="placeholder"
/>
</view>
<view class="field-row">
<text class="field-row__label">详细地址</text>
<input
v-model="form.addressDetail"
maxlength="60"
placeholder="请输入详细地址"
placeholder-class="placeholder"
/>
</view>
<view class="intro-field">
<text class="intro-field__label">家谱简介</text>
<textarea
v-model="form.intro"
maxlength="200"
auto-height
placeholder="可记录迁徙、家训或建谱缘由"
placeholder-class="placeholder"
/>
</view>
<view class="cover-field">
<view>
<text class="cover-field__label">封面图片</text>
<text class="cover-field__hint"
>图片上传成功后会作为家谱封面保存</text
>
</view>
<button
class="upload-button"
:disabled="isUploading || isSubmitting"
@click="uploadCover"
>
{{ isUploading ? "上传中…" : "选择图片" }}
</button>
<text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
<view class="access-rule">
<text class="access-rule__label">访问规则</text>
<view
class="access-rule__options"
role="radiogroup"
aria-label="家谱访问规则"
>
<view
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
:key="option.value"
class="access-rule__option"
:class="{
'access-rule__option--active':
form.accessPreset === option.value,
}"
role="radio"
:aria-checked="form.accessPreset === option.value"
:aria-label="option.label"
@click="form.accessPreset = option.value"
>{{ option.label }}</view
>
</view>
</view>
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
<AppButton
block
:disabled="isSubmitting || isUploading"
:label="isSubmitting ? '正在创建…' : createdGenealogyId ? '返回我的家谱' : '确认创建家谱'"
@click="submitCreate"
/>
</view>
</view>
<RegionPickerDialog
ref="regionPickerDialog"
:max-levels="3"
@select="selectRegion"
@loading-change="regionLoading = $event"
@error-change="regionPickerError = $event"
@transient-change="regionPickerTransientOpen = $event"
/>
<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, onMounted, reactive, ref } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import RegionPickerDialog from "@/components/genealogy/RegionPickerDialog.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
toApiGenealogyAccess,
} from "@/utils/genealogy/access-policy.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import {
finishPage,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
firstAncestorName: "",
ownerIsFirstAncestor: false,
originPlace: "",
addressDetail: "",
intro: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const fieldErrors = reactive({
surname: "",
genealogyName: "",
firstAncestorName: "",
regionCode: "",
});
const submitError = ref("");
const isSubmitting = ref(false);
const createdGenealogyId = ref("");
const isUploading = ref(false);
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const coverUploadRequestController = createRequestController();
const genealogyCreateRequestController = createRequestController();
const createGenealogyRequestId = () =>
typeof globalThis.crypto?.randomUUID === "function"
? `app-genealogy-${globalThis.crypto.randomUUID()}`
: `app-genealogy-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const genealogyCreateRequestId = ref(createGenealogyRequestId());
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerDialog = ref(null);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerTransientOpen = ref(false);
const discardDialogVisible = ref(false);
let pageActive = true;
const hasDraft = computed(
() =>
Object.entries(form).some(([key, value]) =>
key === "accessPreset"
? value !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
: key === "ownerIsFirstAncestor"
? value === true
: String(value).trim(),
) ||
Boolean(selectedRegion.value?.regionCode) ||
Boolean(coverOssId.value),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardDialogVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const clearFieldError = (field) => {
fieldErrors[field] = "";
submitError.value = "";
};
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.firstAncestorName = form.firstAncestorName.trim()
? ""
: "请填写始迁祖姓名";
fieldErrors.regionCode = selectedRegion.value?.regionCode
? ""
: "请选择所在地区";
return (
!fieldErrors.surname &&
!fieldErrors.genealogyName &&
!fieldErrors.firstAncestorName &&
!fieldErrors.regionCode
);
};
const openRegionPicker = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
await regionPickerDialog.value?.open(selectedRegion.value?.regionCode || "");
};
const selectRegion = ({ region, trail }) => {
selectedRegion.value = region;
regionPickerTrail.value = trail;
fieldErrors.regionCode = "";
regionPickerError.value = "";
};
onMounted(() => {
void regionPickerDialog.value?.prepare();
});
const requestBack = () =>
createdGenealogyId.value
? returnTo("G01")
: runBackGuard({
transientOpen:
regionPickerTransientOpen.value || discardDialogVisible.value,
dirty: hasDraft.value,
submitting: isSubmitting.value || isUploading.value,
"close-transient": () =>
regionPickerTransientOpen.value
? regionPickerDialog.value?.close()
: cancelDiscard(),
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: coverUploadRequestController,
});
if (!pageActive) return;
coverOssId.value = receipt.ossId;
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (
pageActive &&
!isImagePickCancelled(error) &&
!isRequestCancelled(error)
) {
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const submitCreate = async () => {
if (isSubmitting.value || isUploading.value) return;
if (!createdGenealogyId.value && !validate()) return;
let createPayload = null;
if (!createdGenealogyId.value) {
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
submitError.value = "请选择家谱开放方式";
return;
}
createPayload = {
surname: form.surname,
genealogyName: form.genealogyName,
regionCode: selectedRegion.value.regionCode,
ancestralHall: form.ancestralHall,
firstAncestorName: form.firstAncestorName,
ownerIsFirstAncestor: form.ownerIsFirstAncestor,
requestId: genealogyCreateRequestId.value,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
coverOssId: coverOssId.value,
...access,
};
}
isSubmitting.value = true;
submitError.value = "";
try {
if (!createdGenealogyId.value) {
const created = await genealogyApi.createGenealogy(
createPayload,
{ requestController: genealogyCreateRequestController },
);
if (!pageActive) return;
createdGenealogyId.value = created.id;
}
await finishPage(
"G01",
{},
{
operation: "genealogy-created",
entityId: createdGenealogyId.value,
refresh: true,
},
);
} catch (error) {
if (!pageActive) return;
if (isRequestCancelled(error)) return;
submitError.value = createdGenealogyId.value
? "家谱已经创建,但页面返回失败。请再次点击“返回我的家谱”,不要重复创建。"
: getRequestErrorMessage(error, "创建失败,请稍后重试");
} finally {
if (pageActive) isSubmitting.value = false;
}
};
onUnload(() => {
pageActive = false;
coverUploadRequestController.abort();
genealogyCreateRequestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.create-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-content {
z-index: 1;
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
}
.create-card {
@include adaptive-genealogy-state-panel;
padding: 48rpx 38rpx 42rpx;
}
.create-card__eyebrow,
.create-card__title,
.create-card__note,
.field-error,
.submit-error {
display: block;
}
.create-card__eyebrow {
color: $brand-red;
font-size: clamp(16px, 26rpx, 20px);
font-weight: 700;
letter-spacing: 3rpx;
}
.create-card__title {
margin-top: 12rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(22px, 42rpx, 28px);
font-weight: 700;
}
.create-card__note {
margin-top: 16rpx;
color: $ink-muted;
font-size: clamp(15px, 25rpx, 18px);
line-height: 1.65;
}
.field-row {
display: flex;
min-height: 100rpx;
align-items: center;
margin-top: 22rpx;
padding: 0 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.field-row__label {
width: 184rpx;
flex: 0 0 auto;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
white-space: nowrap;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(16px, 26rpx, 20px);
line-height: 1;
transform: translateY(-3rpx);
}
.field-row input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
}
.field-row--selector {
justify-content: space-between;
cursor: pointer;
}
.region-selector-value {
min-width: 0;
flex: 1;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
text-align: right;
overflow-wrap: anywhere;
}
.region-selector-chevron {
width: 28rpx;
height: 28rpx;
margin-left: 12rpx;
flex: 0 0 auto;
opacity: 0.7;
}
.region-selector-value--placeholder {
color: #ab9a86;
}
.placeholder {
color: #ab9a86;
}
.intro-field {
margin-top: 22rpx;
padding: 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.intro-field__label {
display: block;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.intro-field textarea {
display: block;
width: 100%;
min-height: 120rpx;
margin-top: 14rpx;
color: $ink;
font-size: clamp(16px, 27rpx, 20px);
line-height: 1.55;
}
.owner-ancestor-field {
display: flex;
min-height: 112rpx;
align-items: center;
justify-content: space-between;
gap: 24rpx;
margin-top: 22rpx;
padding: 18rpx 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.owner-ancestor-field > view {
min-width: 0;
flex: 1;
}
.owner-ancestor-field__label,
.owner-ancestor-field__hint {
display: block;
}
.owner-ancestor-field__label {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.owner-ancestor-field__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
}
.cover-field {
display: grid;
gap: 12rpx;
margin-top: 22rpx;
padding: 20rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.cover-field__label {
display: block;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.cover-field__hint {
display: block;
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
}
.upload-button {
justify-self: start;
min-height: 88rpx;
margin: 0;
padding: 0 20rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.upload-receipt {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.field-error,
.submit-error {
margin-top: 10rpx;
color: $brand-red;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.5;
}
.access-rule {
margin-top: 28rpx;
}
.access-rule__label {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 31rpx, 22px);
font-weight: 700;
}
.access-rule__options {
display: flex;
margin-top: 16rpx;
gap: 16rpx;
}
.access-rule__option {
flex: 1;
display: flex;
min-height: 88rpx;
box-sizing: border-box;
align-items: center;
justify-content: center;
padding: 20rpx 12rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
color: $ink-muted;
font-size: clamp(15px, 25rpx, 18px);
text-align: center;
}
.access-rule__option--active {
border-color: $brand-red;
background: rgba(159, 23, 15, 0.07);
color: $brand-red;
font-weight: 700;
}
.create-card .app-button {
margin-top: 32rpx;
}
</style>