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

416 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="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" @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>
</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>
<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">
<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 }"
@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 ? '正在创建…' : '确认创建家谱'" @click="submitCreate" />
</view>
</view>
<view v-if="regionPickerOpen" class="region-sheet">
<view class="region-sheet__mask" />
<view class="region-sheet__panel">
<view class="region-sheet__intro">
<text class="region-sheet__title">选择地区</text>
</view>
<view class="region-sheet__picker">
<view class="region-sheet__column-headings">
<text v-for="label in ['省份', '城市', '区县']" :key="label" class="region-sheet__column-heading">{{ label }}</text>
</view>
<picker-view
class="region-sheet__picker-view"
:indicator-style="regionPickerIndicatorStyle"
:value="regionPickerIndexes"
@change="handleRegionPickerChange"
>
<picker-view-column
v-for="(column, columnIndex) in regionPickerColumns"
:key="columnIndex"
>
<view
v-for="(option, optionIndex) in column"
:key="option.regionCode"
class="region-sheet__picker-item"
:class="{ 'region-sheet__picker-item--selected': regionPickerIndexes[columnIndex] === optionIndex }"
>{{ option.label }}</view>
</picker-view-column>
</picker-view>
</view>
<view class="region-sheet__footer">
<view class="region-sheet__cancel" @click="closeRegionPicker">取消</view>
<button class="region-sheet__confirm" @click="confirmRegionSelection">确认选择</button>
</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, 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/GenealogyPageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { GENEALOGY_ACCESS_PRESET, GENEALOGY_ACCESS_PRESET_OPTIONS, toApiGenealogyAccess } from "@/utils/genealogy-contracts.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { finishPage, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const form = reactive({
surname: "",
genealogyName: "",
ancestralHall: "",
originPlace: "",
addressDetail: "",
intro: "",
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
});
const fieldErrors = reactive({ surname: "", genealogyName: "", regionCode: "" });
const submitError = ref("");
const isSubmitting = ref(false);
const isUploading = ref(false);
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const createController = createRequestController();
const regionController = createRequestController();
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerColumns = ref([]);
const regionPickerIndexes = ref([0]);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerOpen = ref(false);
const discardDialogVisible = ref(false);
const regionPickerIndicatorStyle = "height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
let pageActive = true;
const hasDraft = computed(() =>
Object.entries(form).some(([key, value]) =>
key === "accessPreset"
? value !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
: 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.regionCode = selectedRegion.value?.regionCode ? "" : "请选择所在地区";
return !fieldErrors.surname && !fieldErrors.genealogyName && !fieldErrors.regionCode;
};
const fetchRegionChildren = async (parentCode) => {
regionLoading.value = true;
regionPickerError.value = "";
try {
return await appApi.getRegionChildren(parentCode, { requestController: regionController });
} catch (error) {
if (!isRequestCancelled(error)) regionPickerError.value = error?.message || "地区列表加载失败,请重试";
return [];
} finally {
regionLoading.value = false;
}
};
const loadRegionRoot = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
const roots = await fetchRegionChildren("0");
if (!roots.length || regionPickerError.value) return;
regionPickerColumns.value = [roots];
regionPickerIndexes.value = [0];
regionPickerTrail.value = [];
await loadPickerColumns();
};
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
const provinces = regionPickerColumns.value[0] || await fetchRegionChildren("0");
const province = provinces[provinceIndex];
if (!province) return;
const cities = await fetchRegionChildren(province.regionCode);
const city = cities[cityIndex];
if (!city) {
regionPickerColumns.value = [provinces];
regionPickerIndexes.value = [provinceIndex];
return;
}
const districts = await fetchRegionChildren(city.regionCode);
if (!districts.length || regionPickerError.value) return;
regionPickerColumns.value = [provinces, cities, districts];
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
};
const handleRegionPickerChange = async (event) => {
if (regionLoading.value) return;
const nextIndexes = (event?.detail?.value || []).map((index) => Number(index) || 0);
const indexes = regionPickerIndexes.value;
const provinceIndex = nextIndexes[0] || 0;
const cityIndex = nextIndexes[1] || 0;
const districtIndex = nextIndexes[2] || 0;
if (provinceIndex !== (indexes[0] || 0)) {
await loadPickerColumns(provinceIndex, 0);
return;
}
if (cityIndex !== (indexes[1] || 0)) {
await loadPickerColumns(indexes[0] || 0, cityIndex);
return;
}
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
};
const openRegionPicker = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
if (!regionPickerColumns.value.length) await loadRegionRoot();
if (regionPickerColumns.value.length) {
regionPickerOpen.value = true;
}
};
const closeRegionPicker = () => {
regionPickerOpen.value = false;
};
const confirmRegionSelection = () => {
const indexes = regionPickerIndexes.value;
const trail = [];
regionPickerColumns.value.forEach((column, index) => {
const option = column[Number(indexes[index])];
if (option) trail.push(option);
});
const region = trail[trail.length - 1];
if (!region) return;
regionPickerIndexes.value = trail.map((_, index) => Number(indexes[index]) || 0);
selectedRegion.value = region;
regionPickerTrail.value = trail;
fieldErrors.regionCode = "";
regionPickerError.value = "";
regionPickerOpen.value = false;
};
onMounted(() => {
loadRegionRoot();
});
const requestBack = () =>
runBackGuard({
transientOpen: regionPickerOpen.value || discardDialogVisible.value,
dirty: hasDraft.value,
submitting: isSubmitting.value || isUploading.value,
"close-transient": () => regionPickerOpen.value ? closeRegionPicker() : 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: createController });
if (!pageActive) return;
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const submitCreate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
submitError.value = "访问规则无效,请重新选择";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
const created = await appApi.createGenealogy({
surname: form.surname,
genealogyName: form.genealogyName,
regionCode: selectedRegion.value.regionCode,
ancestralHall: form.ancestralHall,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
coverOssId: coverOssId.value,
...access,
}, { requestController: createController });
if (!pageActive) return;
await finishPage("G01", {}, {
operation: "genealogy-created",
entityId: created.id,
refresh: true,
});
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = error?.message || "创建失败,请稍后重试";
} finally {
if (pageActive) isSubmitting.value = false;
}
};
onUnload(() => {
pageActive = false;
createController.abort();
regionController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.create-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-content { z-index: 1; padding: 24rpx 32rpx 72rpx; }
.create-card { @include adaptive.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: 26rpx; font-weight: 700; letter-spacing: 3rpx; }
.create-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 42rpx; font-weight: 700; }
.create-card__note { margin-top: 16rpx; color: $ink-muted; font-size: 25rpx; 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, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.field-row__label { width: 142rpx; flex: 0 0 auto; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.field-row input { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; }
.field-row--selector { justify-content: space-between; cursor: pointer; }
.region-selector-value { min-width: 0; flex: 1; color: $ink; font-size: 28rpx; text-align: right; overflow-wrap: anywhere; }
.region-selector-value--placeholder { color: #ab9a86; }
.placeholder { color: #ab9a86; }
.intro-field { margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.intro-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.intro-field textarea { display: block; width: 100%; min-height: 120rpx; margin-top: 14rpx; color: $ink; font-size: 27rpx; line-height: 1.55; }
.cover-field { display: grid; gap: 12rpx; margin-top: 22rpx; padding: 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.cover-field__label { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.cover-field__hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 21rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error, .submit-error { margin-top: 10rpx; color: $brand-red; font-size: 24rpx; line-height: 1.5; }
.access-rule { margin-top: 28rpx; }
.access-rule__label { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.access-rule__options { display: flex; margin-top: 16rpx; gap: 16rpx; }
.access-rule__option { flex: 1; padding: 20rpx 12rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; color: $ink-muted; font-size: 25rpx; text-align: center; }
.access-rule__option--active { border-color: $brand-red; background: rgba(159, 23, 15, .07); color: $brand-red; font-weight: 700; }
.create-card .app-button { margin-top: 32rpx; }
.region-sheet { position: fixed; z-index: 10; inset: 0; display: flex; align-items: flex-end; }
.region-sheet__mask { position: absolute; inset: 0; background: rgba(43, 30, 20, .42); }
.region-sheet__panel { position: relative; width: 100%; padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom)); border-radius: 30rpx 30rpx 0 0; background: #fdf9ef; box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, .2); }
.region-sheet__intro { padding: 0 10rpx 16rpx; text-align: center; }
.region-sheet__title { display: block; }
.region-sheet__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.region-sheet__picker { overflow: hidden; border: 1rpx solid rgba(128, 89, 49, .28); border-radius: 16rpx; background: rgba(255, 252, 245, .8); }
.region-sheet__column-headings { display: flex; height: 84rpx; border-bottom: 1rpx solid rgba(128, 89, 49, .18); }
.region-sheet__column-heading { box-sizing: border-box; width: 33.333%; padding: 24rpx 12rpx; color: $ink-muted; font-size: 26rpx; text-align: center; }
.region-sheet__column-heading + .region-sheet__column-heading { border-left: 1rpx solid rgba(128, 89, 49, .16); }
.region-sheet__picker-view { width: 100%; height: 520rpx; }
.region-sheet__picker-item { box-sizing: border-box; height: 104rpx; overflow: hidden; padding: 0 6rpx; color: $ink-muted; font-size: 26rpx; line-height: 104rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.region-sheet__picker-item--selected { color: $brand-red; font-weight: 700; }
.region-sheet__footer { display: flex; align-items: center; margin-top: 22rpx; gap: 12rpx; }
.region-sheet__cancel { min-width: 116rpx; padding: 20rpx 10rpx; color: $ink-muted; font-size: 28rpx; text-align: center; }
.region-sheet__confirm { flex: 1; height: 82rpx; margin: 0; padding: 0; border: 0; border-radius: 10rpx; background: $brand-red; color: #fff; font-size: 29rpx; font-weight: 700; line-height: 82rpx; }
.region-sheet__confirm::after { display: none; }
</style>