Files
jiapuapp/pages/records/ceremony-editor.vue
T

648 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="ritual-editor-page" :class="`ritual-state--${pageState}`">
<ModulePageBackground module="records" />
<view class="page-header"
><PageHeader :title="isEdit ? '编辑礼仪活动' : '新建礼仪活动'" custom-back @back="requestBack"
/></view>
<view class="page-content">
<view v-if="pageState === 'loading'" class="state-card"><AppLoading text="正在读取礼仪活动" /></view>
<view v-else-if="pageState === 'form'" class="editor-card">
<text class="editor-card__title">{{ isEdit ? '更新这场家族礼仪' : '记录一场家族礼仪' }}</text>
<text class="editor-card__note"
>{{ isEdit ? '未重新选择封面时,会原样保留当前已授权封面。' : '带红色星号的内容不能为空;其余内容可按需要补充。' }}</text
>
<view class="field-row field-row--picker">
<text class="field-row__label"
><text class="required-mark">*</text>活动类型</text
>
<picker
:range="ceremonyTypeLabels"
:value="ceremonyTypeIndex"
:disabled="ceremonyTypeOptionsState !== 'ready' || !ceremonyTypeOptions.length"
@change="selectCeremonyType"
><view :class="{ placeholder: !form.ceremonyType }">{{ ceremonyTypeLabel }}</view></picker>
</view>
<text v-if="ceremonyTypeOptionsState === 'loading'" class="field-row__hint">正在获取活动类型</text>
<text v-else-if="ceremonyTypeOptionsState === 'error'" class="field-row__hint">活动类型暂不可用请稍后重试</text>
<view class="field-row">
<text class="field-row__label"
><text class="required-mark">*</text>活动标题</text
>
<input
v-model="form.ceremonyTitle"
maxlength="60"
placeholder="请输入活动标题"
placeholder-class="placeholder"
@input="clearError"
/>
</view>
<view class="textarea-field">
<text class="textarea-field__label">活动说明</text>
<textarea
v-model="form.ceremonyDesc"
maxlength="500"
auto-height
placeholder="补充活动安排或说明"
placeholder-class="placeholder"
@input="clearError"
/>
</view>
<view class="field-row field-row--picker">
<text class="field-row__label">活动日期</text>
<picker
mode="date"
:value="form.ceremonyDate"
@change="selectCeremonyDate"
><view :class="{ placeholder: !form.ceremonyDate }">{{
form.ceremonyDate || "请选择"
}}</view></picker
>
</view>
<view class="field-row field-row--picker">
<text class="field-row__label">活动时间</text>
<picker
mode="time"
:value="form.ceremonyClock"
@change="selectCeremonyClock"
><view :class="{ placeholder: !form.ceremonyClock }">{{
form.ceremonyClock || "请选择"
}}</view></picker
>
</view>
<view class="field-row">
<text class="field-row__label">地点</text>
<input
v-model="form.location"
maxlength="60"
placeholder="请输入地点"
placeholder-class="placeholder"
@input="clearError"
/>
</view>
<view class="field-row">
<text class="field-row__label">详细地址</text>
<input
v-model="form.locationAddress"
maxlength="120"
placeholder="请输入详细地址"
placeholder-class="placeholder"
@input="clearError"
/>
</view>
<view class="cover-field">
<view>
<text class="cover-field__label">封面图片</text>
<text class="cover-field__hint"
>图片上传成功后会作为活动封面保存</text
>
</view>
<button
class="upload-button"
:disabled="uploading || submitting"
@click="uploadCover"
>
{{ uploading ? "上传中…" : "选择图片" }}
</button>
<text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
>
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || submitting" @click="clearCover">移除封面</button>
</view>
<text v-if="uploadError" class="form-error">{{ uploadError }}</text>
<text v-if="submitError" class="form-error">{{ submitError }}</text>
<AppButton
block
:disabled="uploading || submitting"
:label="submitting ? '正在保存…' : isEdit ? '保存修改' : '保存活动'"
@click="saveCeremony"
/>
</view>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃活动草稿?"
message="活动还没有保存,返回后将不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmation.confirm"
@cancel="confirmation.cancel"
/>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, 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 ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import { ceremonyApi } from "@/services/api/ceremony-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const ceremonyId = ref("");
const mode = ref("");
const pageState = ref("loading");
const form = reactive({
ceremonyType: "",
ceremonyTitle: "",
ceremonyDesc: "",
ceremonyDate: "",
ceremonyClock: "",
location: "",
locationAddress: "",
});
const coverOssId = ref(null);
const coverFileName = ref("");
const preservedUpdateFields = ref({
longitude: null,
latitude: null,
sortOrder: null,
status: "",
});
const formBaseline = ref("");
const ceremonyTypeOptionsState = ref("loading");
const ceremonyTypeOptions = ref([]);
const uploadError = ref("");
const submitError = ref("");
const uploading = ref(false);
const submitting = ref(false);
const ceremonyDetailController = createRequestController();
const coverUploadController = createRequestController();
const ceremonySaveController = createRequestController();
const ceremonyTypeRequestController = createRequestController();
const ceremonyCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const discardVisible = ref(false);
const confirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const isEdit = computed(() => mode.value === "edit");
const hasValidContext = computed(
() =>
/^[1-9]\d*$/.test(genealogyId.value) &&
(mode.value === "create" ||
(mode.value === "edit" && /^[1-9]\d*$/.test(ceremonyId.value))),
);
const ceremonyTypeLabels = computed(() => ceremonyTypeOptions.value.map((item) => item.label));
const ceremonyTypeIndex = computed(() =>
ceremonyTypeOptions.value.findIndex((item) => item.value === form.ceremonyType),
);
const ceremonyTypeLabel = computed(
() => ceremonyTypeOptions.value[ceremonyTypeIndex.value]?.label || "请选择活动类型",
);
const isDirty = computed(
() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Boolean(coverOssId.value) ||
Object.values(form).some((value) => value.trim()),
);
const ceremonyTime = computed(() =>
form.ceremonyDate
? `${form.ceremonyDate} ${form.ceremonyClock || "00:00"}:00`
: "",
);
const formSnapshot = computed(() =>
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
);
const stateCopy = computed(() =>
pageState.value === "success"
? {
title: isEdit.value ? "礼仪活动已更新" : "礼仪活动已保存",
copy: isEdit.value
? "已保存,返回详情可查看最新内容。"
: "已保存。",
action: isEdit.value ? "返回礼仪详情" : "返回礼仪活动",
}
: pageState.value === "error"
? {
title: "暂时无法编辑礼仪活动",
copy: submitError.value || "请返回详情后重新查看。",
action: "返回礼仪详情",
}
: {
title: "暂时无法编辑礼仪活动",
copy: "未找到家谱信息,请返回后重新进入。",
action: "返回上一页",
},
);
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
ceremonyId.value = String(query?.ceremonyId || "");
mode.value = String(query?.mode || "");
if (!hasValidContext.value) {
pageState.value = "invalid";
return;
}
initializeEditor();
});
const initializeEditor = async () => {
pageState.value = "loading";
try {
await loadCeremonyTypes();
if (isEdit.value) await loadCeremonyForEdit();
if (!pageActive) return;
if (pageState.value === "loading") pageState.value = "form";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submitError.value = getRequestErrorMessage(error, "礼仪活动详情暂时无法读取,请稍后重试。");
pageState.value = "error";
}
};
const loadCeremonyTypes = async () => {
ceremonyTypeOptionsState.value = "loading";
try {
const options = await businessDictionaryApi.getBusinessDictionaryOptions("gen_ceremony_type", {
requestController: ceremonyTypeRequestController,
});
if (!pageActive) return;
ceremonyTypeOptions.value = options;
ceremonyTypeOptionsState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
ceremonyTypeOptions.value = [];
ceremonyTypeOptionsState.value = "error";
}
};
const splitDateTime = (value) => {
if (!value) return { date: "", clock: "" };
const matched = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
if (!matched) throw new Error("活动时间有误,暂时无法编辑。");
return { date: matched[1], clock: matched[2] };
};
const loadCeremonyForEdit = async () => {
if (ceremonyTypeOptionsState.value !== "ready") {
throw new Error("暂时无法获取活动类型,未保存修改。");
}
const detail = await ceremonyApi.getCeremonyDetail(genealogyId.value, ceremonyId.value, {
requestController: ceremonyDetailController,
});
if (!pageActive) return;
if (
!detail.canEdit ||
!detail.type ||
!detail.title ||
!["0", "1"].includes(detail.status) ||
!Number.isSafeInteger(detail.sortOrder) ||
!ceremonyTypeOptions.value.some((item) => item.value === detail.type)
) {
throw new Error("活动信息不完整,暂未保存修改,以免覆盖原内容。");
}
const timeParts = splitDateTime(detail.time);
Object.assign(form, {
ceremonyType: detail.type,
ceremonyTitle: detail.title,
ceremonyDesc: detail.description,
ceremonyDate: timeParts.date,
ceremonyClock: timeParts.clock,
location: detail.location,
locationAddress: detail.locationAddress,
});
coverOssId.value = detail.coverFile?.ossId || null;
coverFileName.value = detail.coverFile?.fileName || (detail.coverFile ? "当前活动封面" : "");
preservedUpdateFields.value = {
longitude: detail.longitude,
latitude: detail.latitude,
sortOrder: detail.sortOrder,
status: detail.status,
};
formBaseline.value = formSnapshot.value;
};
const clearError = () => {
submitError.value = "";
};
const selectCeremonyDate = (event) => {
form.ceremonyDate = event.detail.value || "";
clearError();
};
const selectCeremonyClock = (event) => {
form.ceremonyClock = event.detail.value || "";
clearError();
};
const selectCeremonyType = (event) => {
form.ceremonyType = ceremonyTypeOptions.value[Number(event.detail.value)]?.value || "";
clearError();
};
const uploadCover = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: coverUploadController,
});
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) uploading.value = false;
}
};
const clearCover = () => {
if (uploading.value || submitting.value) return;
coverOssId.value = null;
coverFileName.value = "";
uploadError.value = "";
};
const saveCeremony = async () => {
if (uploading.value || submitting.value || !hasValidContext.value) return;
if (!form.ceremonyType.trim() || !form.ceremonyTitle.trim()) {
submitError.value = !form.ceremonyType.trim()
? "请填写活动类型"
: "请填写活动标题";
return;
}
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
const payload = {
...ceremonyForm,
ceremonyTime: ceremonyTime.value,
coverOssId: coverOssId.value,
...(isEdit.value ? preservedUpdateFields.value : {}),
};
const createAttempt = isEdit.value ? null : ceremonyCreateGuard.begin(payload);
if (!isEdit.value && createAttempt === null) {
submitError.value =
"上次保存结果暂时无法确认,请先返回活动列表检查,避免重复创建。";
return;
}
submitting.value = true;
submitError.value = "";
try {
if (isEdit.value) {
await ceremonyApi.updateCeremony(genealogyId.value, ceremonyId.value, payload, {
requestController: ceremonySaveController,
});
} else {
await ceremonyApi.createCeremony(genealogyId.value, payload, {
requestController: ceremonySaveController,
});
}
if (!pageActive) return;
pageState.value = "success";
} catch (error) {
if (!pageActive) return;
if (!isEdit.value && ceremonyCreateGuard.recordFailure(createAttempt, error)) {
submitError.value =
"保存结果暂时无法确认,请先返回活动列表检查,避免重复创建。";
return;
}
if (!isRequestCancelled(error))
submitError.value = getRequestErrorMessage(error, "礼仪活动保存失败,请稍后重试");
} finally {
if (pageActive) submitting.value = false;
}
};
const returnToDestination = () =>
hasValidContext.value
? isEdit.value
? returnTo("R06", { genealogyId: genealogyId.value, ceremonyId: ceremonyId.value })
: returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: uploading.value || submitting.value,
"close-transient": confirmation.cancel,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
const handleStateAction = () =>
pageState.value === "success" || pageState.value === "error"
? returnToDestination()
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
ceremonyDetailController.abort();
coverUploadController.abort();
ceremonySaveController.abort();
ceremonyTypeRequestController.abort();
confirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.ritual-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
flex: 1;
padding: 22rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
}
.editor-card,
.state-card {
@include adaptive-records-content;
box-sizing: border-box;
background-color: rgba($paper, 0.82);
}
.editor-card {
padding: 38rpx 32rpx 42rpx;
}
.editor-card__title,
.editor-card__note,
.field-row__label,
.textarea-field__label,
.field-row__hint,
.form-error {
display: block;
}
.editor-card__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(20px, 38rpx, 26px);
font-weight: 700;
}
.editor-card__note {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.55;
}
.field-row {
display: flex;
min-height: 92rpx;
align-items: center;
margin-top: 20rpx;
padding: 0 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.field-row__label {
width: 150rpx;
flex: 0 0 auto;
color: $ink;
font-size: clamp(16px, 26rpx, 20px);
font-weight: 700;
}
.field-row input {
min-width: 0;
flex: 1;
color: $ink;
font-size: clamp(15px, 25rpx, 18px);
}
.field-row--picker picker {
min-width: 0;
flex: 1;
}
.field-row--picker picker > view {
min-height: 92rpx;
display: flex;
align-items: center;
color: $ink;
font-size: clamp(15px, 25rpx, 18px);
}
.field-row__hint {
margin: 8rpx 8rpx 0;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(16px, 26rpx, 20px);
transform: translateY(-3rpx);
}
.placeholder {
color: #9e8e79;
}
.textarea-field,
.cover-field {
margin-top: 20rpx;
padding: 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.textarea-field__label,
.cover-field__label {
color: $ink;
font-size: clamp(16px, 26rpx, 20px);
font-weight: 700;
}
.textarea-field textarea {
display: block;
width: 100%;
min-height: 122rpx;
margin-top: 12rpx;
color: $ink;
font-size: clamp(15px, 25rpx, 18px);
line-height: 1.55;
}
.cover-field {
display: grid;
gap: 12rpx;
}
.cover-field__hint {
display: block;
margin-top: 5rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 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;
}
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.form-error {
margin-top: 10rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.45;
}
.editor-card .app-button {
margin-top: 28rpx;
}
.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-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>