Files
jiapuapp/pages/genealogy/settings.vue
T

729 lines
22 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="settings-page" :class="`settings-state--${pageState}`">
<GenealogyPageBackground />
<view class="page-header"
><PageHeader title="家谱设置" custom-back @back="returnToOverview"
/></view>
<view class="page-content">
<AppLoading
v-if="pageState === 'loading'"
text="正在读取家谱设置"
description="请稍候,正在同步当前家谱信息。"
/>
<view v-else-if="pageState === 'form'" class="settings-card">
<text class="settings-card__title">编辑家谱信息</text>
<view v-if="lifecycleStatus === GENEALOGY_LIFECYCLE_STATUS.ARCHIVED" class="lifecycle-note">
<text>这部家谱已归档</text>
<text>归档期间可以查看内容但不能修改恢复后可继续编辑</text>
</view>
<text class="settings-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': !regionDisplay }"
>{{
regionDisplay ||
(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>
<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">{{
coverOssId
? "当前已有封面;重新选择后会用新图片替换。"
: "图片上传成功后,会作为家谱封面保存。"
}}</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 || lifecycleStatus === GENEALOGY_LIFECYCLE_STATUS.ARCHIVED"
:label="isSubmitting ? '正在保存…' : '保存设置'"
@click="submitUpdate"
/>
<view v-if="canArchive || canRestore" class="lifecycle-actions">
<text>{{ canRestore ? "需要继续维护这部家谱时,可以恢复编辑。" : "暂时不再维护时,可以归档;内容仍可查看。" }}</text>
<AppButton
block
type="secondary"
:disabled="lifecycleSubmitting"
:label="lifecycleSubmitting ? '正在处理' : canRestore ? '恢复家谱' : '归档家谱'"
@click="requestLifecycleChange"
/>
</view>
</view>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppDialog
:visible="lifecycleDialogVisible"
eyebrow="家谱状态"
:title="canRestore ? '恢复这部家谱?' : '归档这部家谱?'"
:message="canRestore ? '恢复后可继续修改家谱内容。' : '归档后内容仍可查看,但暂时不能修改。'"
:confirm-text="lifecycleSubmitting ? '正在处理' : canRestore ? '确认恢复' : '确认归档'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmLifecycleChange"
@cancel="lifecycleDialogVisible = false"
/>
<RegionPickerDialog
ref="regionPickerDialog"
close-on-mask
@select="selectRegion"
@loading-change="regionLoading = $event"
@error-change="regionPickerError = $event"
/>
</view>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import RegionPickerDialog from "@/components/genealogy/RegionPickerDialog.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
GENEALOGY_LIFECYCLE_STATUS
} from "@/services/api/genealogy-contract.js";
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 {
GENEALOGY_ACCESS_PRESET,
GENEALOGY_ACCESS_PRESET_OPTIONS,
isGenealogyAccessPreset,
toApiGenealogyAccess,
} from "@/utils/genealogy/access-policy.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const pageState = ref("loading");
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 lifecycleStatus = ref(GENEALOGY_LIFECYCLE_STATUS.NORMAL);
const canArchive = ref(false);
const canRestore = ref(false);
const lifecycleDialogVisible = ref(false);
const lifecycleSubmitting = ref(false);
const currentRegionCode = ref("");
const currentRegionDisplay = ref("");
const selectedRegion = ref(null);
const regionPickerTrail = ref([]);
const regionPickerDialog = ref(null);
const regionPickerError = ref("");
const regionLoading = ref(false);
const settingsReadRequestController = createRequestController();
const genealogyLifecycleRequestController = createRequestController();
const coverUploadRequestController = createRequestController();
const settingsSaveRequestController = createRequestController();
// 封面上传、归档和设置保存属于独立操作。分别持有取消槽,避免后发操作
// 取消前一条仍在收敛的请求,留下无法解释的加载状态。
let pageActive = true;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const regionDisplay = computed(() =>
regionPickerTrail.value.length
? regionPickerTrail.value.map((regionNode) => regionNode.label).join(" / ")
: currentRegionDisplay.value,
);
const stateCopy = computed(() => {
if (pageState.value === "success") {
return {
title: "家谱设置已保存",
copy: "家谱设置已保存。",
action: "返回家谱总览",
};
}
if (!hasValidContext.value) {
return {
title: "暂时无法打开家谱设置",
copy: "未找到家谱信息,请返回后重新进入。",
action: "返回上一页",
};
}
return {
title: "家谱设置暂时无法读取",
copy: "请检查网络后重新读取。",
action: "重新读取",
};
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
submitError.value = "";
};
const validate = () => {
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
fieldErrors.regionCode =
selectedRegion.value?.regionCode || currentRegionCode.value
? ""
: "请选择所在地区";
return (
!fieldErrors.surname &&
!fieldErrors.genealogyName &&
!fieldErrors.regionCode
);
};
const openRegionPicker = async () => {
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
await regionPickerDialog.value?.open(currentRegionCode.value);
};
const selectRegion = ({ region, trail }) => {
selectedRegion.value = region;
currentRegionCode.value = region.regionCode;
regionPickerTrail.value = trail;
fieldErrors.regionCode = "";
regionPickerError.value = "";
};
const loadSettings = async () => {
if (!hasValidContext.value) {
pageState.value = "invalid";
return;
}
pageState.value = "loading";
try {
const settings = await genealogyApi.getGenealogySettings(genealogyId.value, {
requestController: settingsReadRequestController,
});
if (!pageActive) return;
if (!isGenealogyAccessPreset(settings.accessPreset)) {
pageState.value = "error";
return;
}
Object.assign(form, {
surname: settings.surname,
genealogyName: settings.genealogyName,
ancestralHall: settings.ancestralHall,
originPlace: settings.originPlace,
addressDetail: settings.addressDetail,
intro: settings.intro,
accessPreset: settings.accessPreset,
});
currentRegionCode.value = settings.regionCode;
currentRegionDisplay.value = settings.regionFullName || settings.regionName;
coverOssId.value = settings.coverFile?.ossId ?? null;
coverFileName.value = settings.coverFile?.fileName || "";
lifecycleStatus.value = settings.lifecycleStatus;
canArchive.value = settings.canArchive;
canRestore.value = settings.canRestore;
pageState.value = "form";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageState.value = "error";
}
};
const requestLifecycleChange = () => {
if ((!canArchive.value && !canRestore.value) || lifecycleSubmitting.value) return;
submitError.value = "";
lifecycleDialogVisible.value = true;
};
const confirmLifecycleChange = async () => {
if ((!canArchive.value && !canRestore.value) || lifecycleSubmitting.value) return;
lifecycleSubmitting.value = true;
try {
const lifecycleResult = canRestore.value
? await genealogyApi.restoreGenealogy(genealogyId.value, {
requestController: genealogyLifecycleRequestController,
})
: await genealogyApi.archiveGenealogy(genealogyId.value, {
requestController: genealogyLifecycleRequestController,
});
if (!pageActive) return;
lifecycleStatus.value = lifecycleResult.lifecycleStatus;
canArchive.value = lifecycleResult.canArchive;
canRestore.value = lifecycleResult.canRestore;
lifecycleDialogVisible.value = false;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = getRequestErrorMessage(error, canRestore.value ? "家谱恢复失败,请稍后重试。" : "家谱归档失败,请稍后重试。");
lifecycleDialogVisible.value = false;
} finally {
if (pageActive) lifecycleSubmitting.value = false;
}
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const coverUpload = await pickAndUploadImage({
requestController: coverUploadRequestController,
});
if (!pageActive) return;
coverOssId.value = coverUpload.ossId;
coverFileName.value = coverUpload.fileName || "封面图片";
} catch (error) {
if (
pageActive &&
!isImagePickCancelled(error) &&
!isRequestCancelled(error)
) {
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
}
} finally {
if (pageActive) isUploading.value = false;
}
};
const submitUpdate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
if (!access) {
submitError.value = "请选择家谱开放方式";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await genealogyApi.updateGenealogy(
genealogyId.value,
{
surname: form.surname,
genealogyName: form.genealogyName,
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
ancestralHall: form.ancestralHall,
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
...access,
},
{ requestController: settingsSaveRequestController },
);
if (!pageActive) return;
pageState.value = "success";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = getRequestErrorMessage(error, "家谱设置保存失败,请稍后重试");
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const returnToOverview = () =>
hasValidContext.value
? returnTo("G05", { genealogyId: genealogyId.value })
: goBack();
const handleStateAction = () =>
pageState.value === "success" ? returnToOverview() : loadSettings();
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
});
onMounted(() => {
void loadSettings();
});
onUnload(() => {
pageActive = false;
settingsReadRequestController.abort();
genealogyLifecycleRequestController.abort();
coverUploadRequestController.abort();
settingsSaveRequestController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.settings-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
flex: 1;
padding: 24rpx 32rpx 72rpx;
}
.settings-card,
.state-card {
@include adaptive-genealogy-state-panel;
box-sizing: border-box;
}
.settings-card {
padding: 42rpx 38rpx;
}
.settings-card__title,
.settings-card__note,
.field-error,
.submit-error {
display: block;
}
.settings-card__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(20px, 40rpx, 26px);
font-weight: 700;
}
.settings-card__note {
margin-top: 12rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.55;
}
.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;
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;
}
.lifecycle-note,
.lifecycle-actions {
margin-top: 20rpx;
padding: 20rpx 22rpx;
border: 1rpx solid rgba(159, 23, 15, 0.26);
border-radius: 10rpx;
background: rgba(159, 23, 15, 0.05);
}
.lifecycle-note text,
.lifecycle-actions text { display: block; color: $ink-muted; font-size: clamp(14px, 22rpx, 17px); line-height: 1.55; }
.lifecycle-note text:first-child { color: $brand-red; font-weight: 700; }
.lifecycle-actions .app-button { margin-top: 18rpx; }
.region-selector-chevron {
width: 28rpx;
height: 28rpx;
margin-left: 12rpx;
flex: 0 0 auto;
opacity: 0.7;
}
.region-selector-value--placeholder,
.placeholder {
color: #ab9a86;
}
.intro-field,
.cover-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,
.cover-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;
}
.cover-field {
display: grid;
gap: 12rpx;
}
.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;
}
.settings-card .app-button {
margin-top: 32rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card text {
display: block;
}
.state-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 35rpx, 24px);
font-weight: 700;
}
.state-card text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
</style>