feat: migrate app routes and business modules
This commit is contained in:
@@ -11,13 +11,13 @@
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>礼仪活动入口无效</text
|
||||
><text>暂时无法打开礼仪活动</text
|
||||
><AppButton block label="返回上一页" @click="returnToFamily"
|
||||
/></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"
|
||||
<view v-else-if="ceremonyListState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取礼仪活动"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
<view v-else-if="ceremonyListState === 'error'" class="state-card"
|
||||
><text>暂时无法读取礼仪活动</text
|
||||
><AppButton
|
||||
block
|
||||
@@ -25,7 +25,7 @@
|
||||
label="重新加载"
|
||||
@click="loadCeremonies"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
<view v-else-if="ceremonyListState === 'empty'" class="state-card"
|
||||
><text>还没有礼仪活动</text
|
||||
><AppButton block label="新建礼仪活动" @click="createCeremony"
|
||||
/></view>
|
||||
@@ -37,7 +37,7 @@
|
||||
@click="openCeremony(item)"
|
||||
><view
|
||||
><text>{{ item.title }}</text
|
||||
><text>{{ item.type }}{{ item.time ? ` · ${item.time}` : "" }}</text
|
||||
><text>{{ item.typeLabel }}{{ item.time ? ` · ${item.time}` : "" }}</text
|
||||
><text v-if="item.description" class="ceremony-card__description">{{ item.description }}</text></view
|
||||
><text>{{ item.giftCount }} 笔献礼</text></view
|
||||
></view
|
||||
@@ -54,41 +54,32 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const ceremonies = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const ceremonyListState = ref("loading");
|
||||
const ceremonyListRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadCeremonies = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
ceremonyListRequestController.abort();
|
||||
ceremonyListState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getCeremonies(genealogyId.value, {
|
||||
requestController: controller,
|
||||
const rows = await ceremonyApi.getCeremonies(genealogyId.value, {
|
||||
requestController: ceremonyListRequestController,
|
||||
});
|
||||
if (!active) return;
|
||||
ceremonies.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.ceremonyId),
|
||||
title: String(item.ceremonyTitle || "未命名活动"),
|
||||
type: String(item.ceremonyType || ""),
|
||||
time: String(item.ceremonyTime || ""),
|
||||
description: String(item.ceremonyDesc || ""),
|
||||
giftCount: Number.isSafeInteger(item.giftCount) ? item.giftCount : 0,
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = ceremonies.value.length ? "ready" : "empty";
|
||||
if (!pageActive) return;
|
||||
ceremonies.value = rows;
|
||||
ceremonyListState.value = ceremonies.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
ceremonyListState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
@@ -108,11 +99,11 @@ onLoad((query) => {
|
||||
if (valid.value) loadCeremonies();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadCeremonies();
|
||||
if (valid.value && ceremonyListState.value !== "loading") loadCeremonies();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
pageActive = false;
|
||||
ceremonyListRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
<template>
|
||||
<view class="ritual-detail-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="礼仪详情"
|
||||
:action="ceremonyDetailState === 'ready' ? (giftFormVisible ? '收起' : '登记献礼') : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="toggleGiftForm"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card">
|
||||
<text>暂时无法打开礼仪活动</text>
|
||||
<text>未找到这项活动,请从活动列表重新进入。</text>
|
||||
<AppButton block label="返回上一页" @click="returnToList" />
|
||||
</view>
|
||||
<view v-else-if="ceremonyDetailState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在加载礼仪详情" />
|
||||
</view>
|
||||
<view v-else-if="ceremonyDetailState === 'error'" class="state-card">
|
||||
<text>暂时无法加载礼仪详情</text>
|
||||
<text>请检查网络后重新加载。</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadDetail" />
|
||||
</view>
|
||||
<view v-else>
|
||||
<view class="detail-card">
|
||||
<text>{{ detail.title }}</text>
|
||||
<text>{{ detail.typeLabel }}{{ detail.time ? ` · ${detail.time}` : "" }}</text>
|
||||
<text v-if="detail.location || detail.locationAddress">地点:{{ detail.location || detail.locationAddress }}</text>
|
||||
<text v-if="detail.description">{{ detail.description }}</text>
|
||||
<text>已有 {{ detail.giftCount }} 笔献礼</text>
|
||||
<view v-if="detail.canEdit || detail.canDelete" class="detail-card__actions">
|
||||
<AppButton v-if="detail.canEdit" compact type="secondary" label="编辑活动" @click="openEditCeremony" />
|
||||
<AppButton v-if="detail.canDelete" compact type="secondary" label="删除活动" @click="requestDeleteCeremony" />
|
||||
</view>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
</view>
|
||||
<view class="gift-list-card">
|
||||
<view class="gift-list-card__heading">
|
||||
<view>
|
||||
<text>献礼记录</text>
|
||||
<text>仅展示可查看的姓名、金额、留言和时间。</text>
|
||||
</view>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="giftListState === 'loading'"
|
||||
:label="giftListState === 'loading' ? '加载中' : '查看清单'"
|
||||
@click="loadGiftRecords"
|
||||
/>
|
||||
</view>
|
||||
<AppLoading v-if="giftListState === 'loading'" text="正在加载献礼记录" />
|
||||
<view v-else-if="giftListState === 'ready'" class="gift-list-card__rows">
|
||||
<view v-for="item in giftRows" :key="item.id" class="gift-list-card__row">
|
||||
<view>
|
||||
<text>{{ item.giverName || item.giverNickName || '未署名献礼' }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.message">{{ item.message }}</text>
|
||||
</view>
|
||||
<view class="gift-list-card__amount">
|
||||
<text>¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click="requestDeleteGift(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="giftListState === 'empty'" class="gift-list-card__empty">
|
||||
<text>暂时没有献礼记录。</text>
|
||||
</view>
|
||||
<view v-else-if="giftListState === 'error'" class="gift-list-card__error">
|
||||
<text>暂时无法加载献礼记录。</text>
|
||||
<AppButton compact type="secondary" label="重新查看" @click="loadGiftRecords" />
|
||||
</view>
|
||||
</view>
|
||||
<CeremonyInvitationManager
|
||||
ref="ceremonyInvitationManager"
|
||||
:genealogy-id="genealogyId"
|
||||
:ceremony-id="ceremonyId"
|
||||
@transient-change="invitationTransientOpen = $event"
|
||||
@busy-change="invitationBusy = $event"
|
||||
/>
|
||||
<view v-if="giftFormVisible" class="gift-form-card">
|
||||
<text class="gift-form-card__title">登记献礼</text>
|
||||
<text class="gift-form-card__note">金额为必填项;赠礼人和留言可按实际情况填写。</text>
|
||||
<view class="gift-field">
|
||||
<text>赠礼人</text>
|
||||
<input
|
||||
v-model.trim="giftForm.giverName"
|
||||
aria-label="赠礼人(选填)"
|
||||
:disabled="giftSubmitting"
|
||||
placeholder="请输入赠礼人姓名(选填)"
|
||||
placeholder-class="gift-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="gift-field">
|
||||
<text><text class="required-mark">*</text>金额</text>
|
||||
<input
|
||||
v-model.trim="giftForm.giftAmount"
|
||||
type="digit"
|
||||
aria-label="献礼金额"
|
||||
:aria-invalid="Boolean(giftError)"
|
||||
:disabled="giftSubmitting"
|
||||
placeholder="请输入献礼金额"
|
||||
placeholder-class="gift-placeholder"
|
||||
@input="giftError = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="gift-message-field">
|
||||
<text>留言</text>
|
||||
<textarea
|
||||
v-model.trim="giftForm.giftMessage"
|
||||
auto-height
|
||||
aria-label="献礼留言(选填)"
|
||||
:disabled="giftSubmitting"
|
||||
placeholder="写下对家族的祝福或缅怀(选填)"
|
||||
placeholder-class="gift-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="giftError" class="gift-error">{{ giftError }}</text>
|
||||
<view class="gift-form-card__actions">
|
||||
<AppButton type="secondary" label="取消" :disabled="giftSubmitting" @click="closeGiftForm" />
|
||||
<AppButton :label="giftSubmitting ? '正在登记' : '确认登记献礼'" :disabled="giftSubmitting" @click="requestGiftSubmit" />
|
||||
</view>
|
||||
</view>
|
||||
<text
|
||||
v-if="giftResult"
|
||||
class="gift-result"
|
||||
:class="`gift-result--${giftResultTone}`"
|
||||
:role="giftResultTone === 'error' ? 'alert' : 'status'"
|
||||
>{{ giftResult }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="giftConfirmVisible"
|
||||
eyebrow="献礼确认"
|
||||
title="确认登记本次献礼?"
|
||||
message="提交后会保存到当前礼仪活动。"
|
||||
:confirm-text="giftSubmitting ? '正在登记' : '确认登记'"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmGiftSubmit"
|
||||
@cancel="giftConfirmVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="giftDiscardVisible"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃本次献礼?"
|
||||
message="当前填写内容尚未提交,关闭后将被清除。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmGiftDiscard"
|
||||
@cancel="cancelGiftDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="ceremonyDeleteConfirmVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这项礼仪活动?"
|
||||
message="活动和相关内容会一并删除,删除后无法恢复。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留活动"
|
||||
show-cancel
|
||||
@confirm="deleteCeremony"
|
||||
@cancel="closeCeremonyDeleteConfirmation"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="giftDeleteConfirmVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="删除确认"
|
||||
title="删除这笔献礼记录?"
|
||||
message="删除后无法恢复,请确认当前记录不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
@confirm="deleteGift"
|
||||
@cancel="closeGiftDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</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 CeremonyInvitationManager from "@/components/records/CeremonyInvitationManager.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { ceremonyApi } from "@/services/api/ceremony-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const ceremonyId = ref("");
|
||||
const ceremonyDetailState = ref("loading");
|
||||
const detail = ref({
|
||||
title: "",
|
||||
type: "",
|
||||
time: "",
|
||||
location: "",
|
||||
locationAddress: "",
|
||||
description: "",
|
||||
giftCount: 0,
|
||||
});
|
||||
// 详情与献礼可以同时刷新,独立控制器可避免一个区域取消另一区域的读取。
|
||||
const ceremonyDetailRequestController = createRequestController();
|
||||
const giftSubmissionRequestController = createRequestController();
|
||||
const ceremonyGiftCreateGuard = createNonIdempotentWriteGuard();
|
||||
const giftListRequestController = createRequestController();
|
||||
const giftFormVisible = ref(false);
|
||||
const giftConfirmVisible = ref(false);
|
||||
const giftDiscardVisible = ref(false);
|
||||
const giftSubmitting = ref(false);
|
||||
const giftError = ref("");
|
||||
const giftResult = ref("");
|
||||
const giftResultTone = ref("");
|
||||
const giftForm = reactive({
|
||||
giverName: "",
|
||||
giftAmount: "",
|
||||
giftMessage: "",
|
||||
});
|
||||
const giftBaseline = ref("");
|
||||
const giftRows = ref([]);
|
||||
const giftListState = ref("idle");
|
||||
const ceremonyInvitationManager = ref(null);
|
||||
const invitationTransientOpen = ref(false);
|
||||
const invitationBusy = ref(false);
|
||||
const ceremonyDeleteConfirmVisible = ref(false);
|
||||
const giftDeleteConfirmVisible = ref(false);
|
||||
const giftDeleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const ceremonyContentDeletionRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value),
|
||||
);
|
||||
const giftSnapshot = computed(() => JSON.stringify(giftForm));
|
||||
const giftDirty = computed(
|
||||
() => giftFormVisible.value && giftSnapshot.value !== giftBaseline.value,
|
||||
);
|
||||
const isPageWriteBusy = computed(
|
||||
() => giftSubmitting.value || invitationBusy.value || deleting.value,
|
||||
);
|
||||
const resetGiftForm = () => {
|
||||
Object.assign(giftForm, { giverName: "", giftAmount: "", giftMessage: "" });
|
||||
giftBaseline.value = JSON.stringify(giftForm);
|
||||
giftError.value = "";
|
||||
};
|
||||
const giftDiscard = createDiscardConfirmation((visible) => {
|
||||
giftDiscardVisible.value = visible;
|
||||
});
|
||||
const requestGiftDiscard = giftDiscard.request;
|
||||
const confirmGiftDiscard = giftDiscard.confirm;
|
||||
const cancelGiftDiscard = giftDiscard.cancel;
|
||||
|
||||
const loadDetail = async ({ preserveCurrent = false } = {}) => {
|
||||
if (!valid.value) return false;
|
||||
ceremonyDetailRequestController.abort();
|
||||
if (!preserveCurrent) ceremonyDetailState.value = "loading";
|
||||
try {
|
||||
const ceremonyDetail = await ceremonyApi.getCeremonyDetail(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
{ requestController: ceremonyDetailRequestController },
|
||||
);
|
||||
if (!pageActive) return false;
|
||||
detail.value = ceremonyDetail;
|
||||
ceremonyDetailState.value = "ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return false;
|
||||
if (!preserveCurrent) ceremonyDetailState.value = "error";
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadGiftRecords = async () => {
|
||||
if (!valid.value || giftListState.value === "loading") return;
|
||||
giftListRequestController.abort();
|
||||
giftListState.value = "loading";
|
||||
try {
|
||||
const rows = await ceremonyApi.getCeremonyGifts(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
{ requestController: giftListRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
giftRows.value = rows;
|
||||
giftListState.value = rows.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
giftListState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const openGiftForm = () => {
|
||||
resetGiftForm();
|
||||
giftResult.value = "";
|
||||
giftResultTone.value = "";
|
||||
giftFormVisible.value = true;
|
||||
};
|
||||
const closeGiftForm = async () => {
|
||||
if (giftSubmitting.value) return false;
|
||||
if (giftDirty.value && !(await requestGiftDiscard())) return false;
|
||||
giftConfirmVisible.value = false;
|
||||
giftFormVisible.value = false;
|
||||
resetGiftForm();
|
||||
return true;
|
||||
};
|
||||
const toggleGiftForm = async () => {
|
||||
if (giftFormVisible.value) return closeGiftForm();
|
||||
openGiftForm();
|
||||
return true;
|
||||
};
|
||||
const requestGiftSubmit = () => {
|
||||
giftError.value = giftForm.giftAmount.trim() ? "" : "请填写献礼金额";
|
||||
if (!giftError.value && !giftSubmitting.value) giftConfirmVisible.value = true;
|
||||
};
|
||||
const confirmGiftSubmit = async () => {
|
||||
if (giftSubmitting.value || !giftForm.giftAmount.trim()) return;
|
||||
const payload = {
|
||||
giverName: giftForm.giverName,
|
||||
giftAmount: giftForm.giftAmount,
|
||||
giftMessage: giftForm.giftMessage,
|
||||
};
|
||||
const createAttempt = ceremonyGiftCreateGuard.begin(payload);
|
||||
if (createAttempt === null) {
|
||||
giftConfirmVisible.value = false;
|
||||
giftError.value =
|
||||
"上次登记结果暂时无法确认,请先查看献礼记录,避免重复登记。";
|
||||
return;
|
||||
}
|
||||
|
||||
giftSubmitting.value = true;
|
||||
giftError.value = "";
|
||||
giftResult.value = "";
|
||||
try {
|
||||
await ceremonyApi.createCeremonyGift(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
payload,
|
||||
{ requestController: giftSubmissionRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
giftConfirmVisible.value = false;
|
||||
giftFormVisible.value = false;
|
||||
resetGiftForm();
|
||||
giftResultTone.value = "success";
|
||||
giftResult.value = "献礼已登记。";
|
||||
const refreshed = await loadDetail({ preserveCurrent: true });
|
||||
if (giftListState.value !== "idle") await loadGiftRecords();
|
||||
if (pageActive && !refreshed) {
|
||||
giftResult.value = "献礼已登记,但礼仪详情暂未刷新。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (ceremonyGiftCreateGuard.recordFailure(createAttempt, error)) {
|
||||
giftError.value =
|
||||
"登记结果暂时无法确认,请先查看献礼记录,避免重复登记。";
|
||||
giftConfirmVisible.value = false;
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
giftError.value = getRequestErrorMessage(error, "献礼登记失败,请稍后重试。");
|
||||
giftConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) giftSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeleteCeremony = () => {
|
||||
if (!detail.value?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
ceremonyDeleteConfirmVisible.value = true;
|
||||
};
|
||||
const openEditCeremony = async () => {
|
||||
if (!detail.value?.canEdit || !valid.value) return;
|
||||
if (!(await closeGiftForm())) return;
|
||||
return openPage(
|
||||
"R07",
|
||||
{ genealogyId: genealogyId.value, ceremonyId: ceremonyId.value, mode: "edit" },
|
||||
"R06",
|
||||
);
|
||||
};
|
||||
const closeCeremonyDeleteConfirmation = () => {
|
||||
if (!deleting.value) ceremonyDeleteConfirmVisible.value = false;
|
||||
};
|
||||
const deleteCeremony = async () => {
|
||||
if (!detail.value?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
let deletionCommitted = false;
|
||||
try {
|
||||
await ceremonyApi.deleteCeremony(genealogyId.value, ceremonyId.value, {
|
||||
requestController: ceremonyContentDeletionRequestController,
|
||||
});
|
||||
deletionCommitted = true;
|
||||
if (!pageActive) return;
|
||||
ceremonyDeleteConfirmVisible.value = false;
|
||||
detail.value = { ...detail.value, canDelete: false, canEdit: false };
|
||||
giftConfirmVisible.value = false;
|
||||
giftFormVisible.value = false;
|
||||
resetGiftForm();
|
||||
const returned = await returnToList();
|
||||
if (pageActive && returned === false) {
|
||||
deleteError.value = "礼仪活动已删除,但暂时无法返回活动列表。";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (deletionCommitted) {
|
||||
ceremonyDeleteConfirmVisible.value = false;
|
||||
detail.value = { ...detail.value, canDelete: false, canEdit: false };
|
||||
deleteError.value = "礼仪活动已删除,但暂时无法返回活动列表。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "礼仪活动删除失败,请稍后重试。");
|
||||
ceremonyDeleteConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteGift = (gift) => {
|
||||
if (!gift?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
giftDeleteTarget.value = gift;
|
||||
giftDeleteConfirmVisible.value = true;
|
||||
};
|
||||
const closeGiftDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
giftDeleteConfirmVisible.value = false;
|
||||
giftDeleteTarget.value = null;
|
||||
};
|
||||
const deleteGift = async () => {
|
||||
const gift = giftDeleteTarget.value;
|
||||
if (!gift?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await ceremonyApi.deleteCeremonyGift(genealogyId.value, ceremonyId.value, gift.id, {
|
||||
requestController: ceremonyContentDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
giftDeleteConfirmVisible.value = false;
|
||||
giftDeleteTarget.value = null;
|
||||
await loadDetail({ preserveCurrent: true });
|
||||
await loadGiftRecords();
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "献礼记录删除失败,请稍后重试。");
|
||||
giftDeleteConfirmVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeActiveTransient = () => {
|
||||
if (giftConfirmVisible.value) {
|
||||
giftConfirmVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (giftDiscardVisible.value) {
|
||||
cancelGiftDiscard();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
invitationTransientOpen.value &&
|
||||
ceremonyInvitationManager.value?.closeTransient()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (ceremonyDeleteConfirmVisible.value) {
|
||||
closeCeremonyDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
if (giftDeleteConfirmVisible.value) {
|
||||
closeGiftDeleteConfirmation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const returnToList = async () => {
|
||||
if (!(await closeGiftForm())) return false;
|
||||
return /^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
};
|
||||
const requestBack = async () => {
|
||||
if (isPageWriteBusy.value) return true;
|
||||
if (closeActiveTransient()) return true;
|
||||
return returnToList();
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
ceremonyId.value = String(query?.ceremonyId || "");
|
||||
if (valid.value) loadDetail();
|
||||
else ceremonyDetailState.value = "invalid";
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
ceremonyDetailRequestController.abort();
|
||||
giftSubmissionRequestController.abort();
|
||||
giftListRequestController.abort();
|
||||
ceremonyContentDeletionRequestController.abort();
|
||||
giftDiscard.dispose();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.ritual-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.detail-card,
|
||||
.gift-list-card,
|
||||
.gift-form-card,
|
||||
.gift-result {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.gift-form-card {
|
||||
margin-top: 18rpx;
|
||||
padding: 34rpx 32rpx;
|
||||
}
|
||||
.gift-form-card__title,
|
||||
.gift-form-card__note,
|
||||
.gift-field > text,
|
||||
.gift-message-field > text,
|
||||
.gift-error,
|
||||
.gift-result {
|
||||
display: block;
|
||||
}
|
||||
.gift-form-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 23px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.gift-form-card__note {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.gift-field {
|
||||
display: grid;
|
||||
grid-template-columns: 148rpx minmax(0, 1fr);
|
||||
min-height: 90rpx;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
border-bottom: 1rpx solid rgba(181, 137, 63, 0.38);
|
||||
}
|
||||
.gift-field > text,
|
||||
.gift-message-field > text {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
color: $brand-red;
|
||||
}
|
||||
.gift-field input,
|
||||
.gift-message-field textarea {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.gift-field input {
|
||||
width: auto;
|
||||
min-height: 70rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.gift-message-field {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.gift-message-field textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 154rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
border: 1rpx solid rgba(181, 137, 63, 0.42);
|
||||
border-radius: 12rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.gift-placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.gift-error {
|
||||
margin-top: 14rpx;
|
||||
color: #b42318;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gift-form-card__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.gift-result {
|
||||
margin-top: 18rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
color: #245f39;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.gift-result--success {
|
||||
border-color: rgba(41, 112, 66, 0.34);
|
||||
background: rgba(234, 246, 237, 0.88);
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.gift-form-card {
|
||||
padding: 28rpx 24rpx;
|
||||
}
|
||||
.gift-field {
|
||||
grid-template-columns: 126rpx minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +1,30 @@
|
||||
<!-- 页面编号:R-07;用途:创建礼仪活动。 -->
|
||||
<template>
|
||||
<view class="ritual-editor-page" :class="`ritual-state--${pageState}`">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader title="新建礼仪活动" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑礼仪活动' : '新建礼仪活动'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="pageState === 'form'" class="editor-card">
|
||||
<text class="editor-card__title">记录一场家族礼仪</text>
|
||||
<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"
|
||||
>带红色星号的内容不能为空;其余内容可按需要补充。</text
|
||||
>{{ isEdit ? '未重新选择封面时,会原样保留当前已授权封面。' : '带红色星号的内容不能为空;其余内容可按需要补充。' }}</text
|
||||
>
|
||||
|
||||
<view class="field-row">
|
||||
<view class="field-row field-row--picker">
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>活动类型</text
|
||||
>
|
||||
<input
|
||||
v-model="form.ceremonyType"
|
||||
maxlength="30"
|
||||
placeholder="例如:祭祖、家宴"
|
||||
placeholder-class="placeholder"
|
||||
@input="clearError"
|
||||
/>
|
||||
<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
|
||||
@@ -93,7 +94,7 @@
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint"
|
||||
>选择图片后会取得真实上传回执,并在保存时关联。</text
|
||||
>图片上传成功后,会作为活动封面保存。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -108,22 +109,12 @@
|
||||
>
|
||||
</view>
|
||||
<text v-if="uploadError" class="form-error">{{ uploadError }}</text>
|
||||
<view class="field-row">
|
||||
<text class="field-row__label">排序值</text>
|
||||
<input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
placeholder-class="placeholder"
|
||||
@input="clearError"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="submitError" class="form-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="uploading || submitting"
|
||||
:label="submitting ? '正在保存…' : '保存活动'"
|
||||
@click="submit"
|
||||
:label="submitting ? '正在保存…' : isEdit ? '保存修改' : '保存活动'"
|
||||
@click="saveCeremony"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="state-card">
|
||||
@@ -136,7 +127,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃活动草稿?"
|
||||
message="当前内容尚未保存到服务端,返回后不会保留。"
|
||||
message="活动还没有保存,返回后将不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -147,31 +138,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
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/resumable-image-upload.js";
|
||||
} 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.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("form");
|
||||
const ceremonyId = ref("");
|
||||
const mode = ref("");
|
||||
const pageState = ref("loading");
|
||||
const form = reactive({
|
||||
ceremonyType: "",
|
||||
ceremonyTitle: "",
|
||||
@@ -180,50 +177,166 @@ const form = reactive({
|
||||
ceremonyClock: "",
|
||||
location: "",
|
||||
locationAddress: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
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 controller = createRequestController();
|
||||
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 hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
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(
|
||||
() =>
|
||||
Boolean(coverOssId.value) ||
|
||||
Object.values(form).some((value) => value.trim()),
|
||||
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: "礼仪活动已提交服务端",
|
||||
copy: "服务端已返回成功结果。",
|
||||
action: "返回礼仪活动",
|
||||
title: isEdit.value ? "礼仪活动已更新" : "礼仪活动已保存",
|
||||
copy: isEdit.value
|
||||
? "已保存,返回详情可查看最新内容。"
|
||||
: "已保存。",
|
||||
action: isEdit.value ? "返回礼仪详情" : "返回礼仪活动",
|
||||
}
|
||||
: pageState.value === "error"
|
||||
? {
|
||||
title: "暂时无法编辑礼仪活动",
|
||||
copy: submitError.value || "请返回详情后重新查看。",
|
||||
action: "返回礼仪详情",
|
||||
}
|
||||
: {
|
||||
title: "礼仪活动入口无效",
|
||||
copy: "没有取得有效家谱标识。",
|
||||
title: "暂时无法编辑礼仪活动",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create")
|
||||
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 = "";
|
||||
};
|
||||
@@ -235,22 +348,29 @@ 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: controller });
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: coverUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "活动封面";
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
const saveCeremony = async () => {
|
||||
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||
if (!form.ceremonyType.trim() || !form.ceremonyTitle.trim()) {
|
||||
submitError.value = !form.ceremonyType.trim()
|
||||
@@ -258,30 +378,52 @@ const submit = async () => {
|
||||
: "请填写活动标题";
|
||||
return;
|
||||
}
|
||||
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
|
||||
const payload = {
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(coverOssId.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 {
|
||||
const { ceremonyDate, ceremonyClock, ...ceremonyForm } = form;
|
||||
await appApi.createCeremony(
|
||||
genealogyId.value,
|
||||
{
|
||||
...ceremonyForm,
|
||||
ceremonyTime: ceremonyTime.value,
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
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 = error?.message || "礼仪活动保存失败,请稍后重试";
|
||||
submitError.value = getRequestErrorMessage(error, "礼仪活动保存失败,请稍后重试");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToList = () =>
|
||||
const returnToDestination = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
? isEdit.value
|
||||
? returnTo("R06", { genealogyId: genealogyId.value, ceremonyId: ceremonyId.value })
|
||||
: returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
@@ -293,10 +435,16 @@ const requestBack = () =>
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
const handleStateAction = () =>
|
||||
pageState.value === "success" ? returnToList() : goBack();
|
||||
pageState.value === "success" || pageState.value === "error"
|
||||
? returnToDestination()
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
ceremonyDetailController.abort();
|
||||
coverUploadController.abort();
|
||||
ceremonySaveController.abort();
|
||||
ceremonyTypeRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -329,6 +477,7 @@ onUnmounted(() => {
|
||||
.editor-card__note,
|
||||
.field-row__label,
|
||||
.textarea-field__label,
|
||||
.field-row__hint,
|
||||
.form-error {
|
||||
display: block;
|
||||
}
|
||||
@@ -378,6 +527,12 @@ onUnmounted(() => {
|
||||
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;
|
||||
@@ -0,0 +1,932 @@
|
||||
<template>
|
||||
<view class="record-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="成长日志"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? '编辑成长记录' : '新建成长记录' }}</text>
|
||||
<text class="form-copy">{{ isEdit ? '原有图片和相关设置会保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
|
||||
<view class="field field--picker">
|
||||
<text>关联人物</text>
|
||||
<picker
|
||||
:range="personOptionLabels"
|
||||
:value="personOptionIndex"
|
||||
:disabled="personOptionsState !== 'ready'"
|
||||
@change="selectLineagePerson"
|
||||
>
|
||||
<view :class="{ placeholder: personOptionsState !== 'ready' }">{{
|
||||
personOptionLabel
|
||||
}}</view>
|
||||
</picker>
|
||||
<text v-if="personOptionsState === 'loading'" class="person-option-note"
|
||||
>正在获取家谱成员</text
|
||||
>
|
||||
<text v-else-if="personOptionsState === 'error'" class="person-option-note"
|
||||
>暂时无法选择关联成员,这条记录不会关联成员。</text
|
||||
>
|
||||
</view>
|
||||
<view class="field field--picker">
|
||||
<text>记录类型</text>
|
||||
<picker
|
||||
:range="growthTypeLabels"
|
||||
:value="growthTypeIndex"
|
||||
:disabled="growthTypeOptionsState !== 'ready' || !growthTypeOptions.length"
|
||||
@change="selectGrowthType"
|
||||
><view :class="{ placeholder: !form.recordType }">{{ growthTypeLabel }}</view></picker>
|
||||
</view>
|
||||
<text v-if="growthTypeOptionsState === 'loading'" class="person-option-note">正在获取记录类型</text>
|
||||
<text v-else-if="growthTypeOptionsState === 'error'" class="person-option-note">暂时无法获取记录类型,可不选类型继续填写。</text>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>记录标题</text
|
||||
><input
|
||||
v-model="form.recordTitle"
|
||||
placeholder="请输入记录标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--textarea"
|
||||
><text>记录内容</text
|
||||
><textarea
|
||||
v-model="form.recordContent"
|
||||
auto-height
|
||||
placeholder="记录成长片段"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.recordDate"
|
||||
@change="selectRecordDate"
|
||||
><view :class="{ placeholder: !form.recordDate }">{{
|
||||
form.recordDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.recordClock"
|
||||
@change="selectRecordClock"
|
||||
><view :class="{ placeholder: !form.recordClock }">{{
|
||||
form.recordClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.remindDate"
|
||||
@change="selectRemindDate"
|
||||
><view :class="{ placeholder: !form.remindDate }">{{
|
||||
form.remindDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view :class="{ placeholder: !form.remindClock }">{{
|
||||
form.remindClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片上传成功后,会随这条记录一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交成长记录'"
|
||||
@click="saveGrowthRecord"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>暂时无法打开成长日志</text
|
||||
><text>未找到家谱信息,请从家族页面重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取成长记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取成长记录</text
|
||||
><text>请检查网络后重新加载。</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有成长记录</text
|
||||
><AppButton block label="新建成长记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>成长记录已保存。</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view class="record-filters">
|
||||
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
|
||||
<view><text>人物</text><text>{{ filterPersonLabels[filterPersonIndex] }}</text></view>
|
||||
</picker>
|
||||
<picker :range="filterTypeLabels" :value="filterTypeIndex" @change="selectFilterType">
|
||||
<view><text>类型</text><text>{{ filterTypeLabels[filterTypeIndex] }}</text></view>
|
||||
</picker>
|
||||
</view>
|
||||
<text v-if="!filteredRecords.length" class="filter-empty">没有符合当前筛选的成长记录。</text>
|
||||
<view
|
||||
v-for="item in filteredRecords"
|
||||
:key="item.id"
|
||||
class="record-card"
|
||||
role="button"
|
||||
:aria-label="`查看${item.title}详情`"
|
||||
@click="openRecordDetail(item)"
|
||||
>
|
||||
<view
|
||||
><text>{{ item.title }}</text
|
||||
><text
|
||||
>{{ growthRecordTypeLabel(item)
|
||||
}}{{ item.date ? ` · ${item.date}` : "" }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<AppButton
|
||||
v-if="item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEdit(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteRecord(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<GrowthRecordDetailDialog
|
||||
ref="growthRecordDetailDialog"
|
||||
:genealogy-id="genealogyId"
|
||||
@busy-change="detailBusy = $event"
|
||||
@transient-change="detailTransientOpen = $event"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃成长记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条成长记录?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteRecord"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.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 { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const records = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const editingRecord = ref(null);
|
||||
const formBaseline = ref("");
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const filterPersonId = ref("");
|
||||
const filterType = ref("");
|
||||
const growthRecordDetailDialog = ref(null);
|
||||
const detailTransientOpen = ref(false);
|
||||
const detailBusy = ref(false);
|
||||
const personOptionsState = ref("loading");
|
||||
const personOptions = ref([{ value: "", label: "不关联人物" }]);
|
||||
const defaultLineagePersonId = ref("");
|
||||
const growthTypeOptionsState = ref("loading");
|
||||
const growthTypeOptions = ref([]);
|
||||
const form = reactive({
|
||||
lineagePersonId: "",
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const recordListRequestController = createRequestController();
|
||||
const growthEditorDetailRequestController = createRequestController();
|
||||
const growthMediaUploadRequestController = createRequestController();
|
||||
const growthRecordSaveRequestController = createRequestController();
|
||||
const personOptionsController = createRequestController();
|
||||
const growthTypeRequestController = createRequestController();
|
||||
const growthRecordDeleteController = createRequestController();
|
||||
const growthRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const shouldIgnoreRequestFailure = (cause) =>
|
||||
!pageActive || isRequestCancelled(cause);
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingRecord.value));
|
||||
const personOptionLabels = computed(() =>
|
||||
personOptions.value.map((item) => item.label),
|
||||
);
|
||||
const personOptionIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
personOptions.value.findIndex((item) => item.value === form.lineagePersonId),
|
||||
),
|
||||
);
|
||||
const personOptionLabel = computed(() =>
|
||||
personOptions.value[personOptionIndex.value]?.label || "不关联人物",
|
||||
);
|
||||
const growthTypeLabels = computed(() => growthTypeOptions.value.map((item) => item.label));
|
||||
const growthTypeIndex = computed(() => Math.max(0, growthTypeOptions.value.findIndex((item) => item.value === form.recordType)));
|
||||
const growthTypeLabel = computed(() => growthTypeOptions.value[growthTypeIndex.value]?.label || "请选择记录类型");
|
||||
const growthRecordTypeLabel = (record) =>
|
||||
growthTypeOptions.value.find((option) => option.value === record.type)?.label ||
|
||||
record.typeLabel ||
|
||||
record.personName;
|
||||
const filterPersonOptions = computed(() => [
|
||||
{ value: "", label: "全部人物" },
|
||||
{ value: "NONE", label: "未关联人物" },
|
||||
...personOptions.value.filter((item) => item.value),
|
||||
]);
|
||||
const filterPersonLabels = computed(() => filterPersonOptions.value.map((item) => item.label));
|
||||
const filterPersonIndex = computed(() => Math.max(0, filterPersonOptions.value.findIndex((item) => item.value === filterPersonId.value)));
|
||||
const filterTypeOptions = computed(() => [{ value: "", label: "全部类型" }, ...growthTypeOptions.value]);
|
||||
const filterTypeLabels = computed(() => filterTypeOptions.value.map((item) => item.label));
|
||||
const filterTypeIndex = computed(() => Math.max(0, filterTypeOptions.value.findIndex((item) => item.value === filterType.value)));
|
||||
const filteredRecords = computed(() => records.value.filter((item) =>
|
||||
(!filterPersonId.value || (filterPersonId.value === "NONE" ? !item.lineagePersonId : item.lineagePersonId === filterPersonId.value)) &&
|
||||
(!filterType.value || item.type === filterType.value),
|
||||
));
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const recordTime = computed(() =>
|
||||
form.recordDate ? `${form.recordDate} ${form.recordClock || "00:00"}:00` : "",
|
||||
);
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
lineagePersonId: defaultLineagePersonId.value,
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingRecord.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
if (!valid.value) return;
|
||||
personOptionsState.value = "loading";
|
||||
try {
|
||||
const rows = await lineageApi.getLineagePersonOptions(genealogyId.value, {
|
||||
requestController: personOptionsController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
const options = rows.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.generation ? `第 ${item.generation} 世 · ${item.name}` : item.name,
|
||||
}));
|
||||
personOptions.value = [{ value: "", label: "不关联人物" }, ...options];
|
||||
defaultLineagePersonId.value = options.some((item) => item.value === personId.value)
|
||||
? personId.value
|
||||
: "";
|
||||
form.lineagePersonId = defaultLineagePersonId.value;
|
||||
personOptionsState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
personOptions.value = [{ value: "", label: "暂时无法选择关联成员" }];
|
||||
defaultLineagePersonId.value = "";
|
||||
form.lineagePersonId = "";
|
||||
personOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadGrowthTypes = async () => {
|
||||
growthTypeOptionsState.value = "loading";
|
||||
try {
|
||||
const growthTypes = await businessDictionaryApi.getBusinessDictionaryOptions("gen_growth_record_type", {
|
||||
requestController: growthTypeRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
growthTypeOptions.value = growthTypes;
|
||||
growthTypeOptionsState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (shouldIgnoreRequestFailure(cause)) return;
|
||||
growthTypeOptions.value = [];
|
||||
growthTypeOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
recordListRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getGrowthRecords(genealogyId.value, {
|
||||
requestController: recordListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
records.value = rows;
|
||||
listState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const splitDateTime = (value, label) => {
|
||||
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(`${label}有误,暂时无法编辑。`);
|
||||
return { date: matched[1], clock: matched[2] };
|
||||
};
|
||||
const openEdit = async (record) => {
|
||||
if (
|
||||
!record?.canEdit ||
|
||||
!valid.value ||
|
||||
submitting.value ||
|
||||
uploading.value ||
|
||||
personOptionsState.value !== "ready" ||
|
||||
growthTypeOptionsState.value !== "ready"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
growthEditorDetailRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getGrowthRecordDetail(genealogyId.value, record.id, "", {
|
||||
requestController: growthEditorDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.content ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder) ||
|
||||
(detail.lineagePersonId && !personOptions.value.some((item) => item.value === detail.lineagePersonId)) ||
|
||||
(detail.type && !growthTypeOptions.value.some((item) => item.value === detail.type))
|
||||
) {
|
||||
throw new Error("这条记录的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const recordTimeParts = splitDateTime(detail.recordDate, "记录时间");
|
||||
const remindTimeParts = splitDateTime(detail.remindTime, "提醒时间");
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
lineagePersonId: detail.lineagePersonId || "",
|
||||
recordType: detail.type,
|
||||
recordTitle: detail.title,
|
||||
recordContent: detail.content,
|
||||
recordDate: recordTimeParts.date,
|
||||
recordClock: recordTimeParts.clock,
|
||||
remindDate: remindTimeParts.date,
|
||||
remindClock: remindTimeParts.clock,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingRecord.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
view.value = "form";
|
||||
listState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRecordDate = (event) => {
|
||||
form.recordDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRecordClock = (event) => {
|
||||
form.recordClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectLineagePerson = (event) => {
|
||||
form.lineagePersonId =
|
||||
personOptions.value[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectGrowthType = (event) => {
|
||||
form.recordType = growthTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectFilterPerson = (event) => {
|
||||
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const selectFilterType = (event) => {
|
||||
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
};
|
||||
const openRecordDetail = (record) =>
|
||||
growthRecordDetailDialog.value?.open({
|
||||
...record,
|
||||
typeLabel: growthRecordTypeLabel(record),
|
||||
});
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const uploadReceipt = await pickAndUploadImage({
|
||||
requestController: growthMediaUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
|
||||
} catch (cause) {
|
||||
if (pageActive && !isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveGrowthRecord = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.recordTitle.trim()) {
|
||||
error.value = "请填写记录标题";
|
||||
return;
|
||||
}
|
||||
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
||||
const payload = {
|
||||
...recordForm,
|
||||
recordDate: recordTime.value,
|
||||
remindTime: remindTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingRecord.value
|
||||
? {
|
||||
sortOrder: editingRecord.value.sortOrder,
|
||||
status: editingRecord.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingRecord.value
|
||||
? null
|
||||
: growthRecordCreateGuard.begin(payload);
|
||||
if (!editingRecord.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回成长记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editingRecord.value) {
|
||||
await lifeRecordApi.updateGrowthRecord(
|
||||
genealogyId.value,
|
||||
editingRecord.value.id,
|
||||
payload,
|
||||
{ requestController: growthRecordSaveRequestController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
|
||||
requestController: growthRecordSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!editingRecord.value &&
|
||||
growthRecordCreateGuard.recordFailure(createAttempt, cause)
|
||||
) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回成长记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "成长记录提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteRecord = (record) => {
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = record;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteRecord = async () => {
|
||||
const record = deleteTarget.value;
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteGrowthRecord(genealogyId.value, record.id, {
|
||||
requestController: growthRecordDeleteController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
deleteError.value = getRequestErrorMessage(cause, "成长记录删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTransientOpen.value
|
||||
? (growthRecordDetailDialog.value?.closeTransient(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value || detailBusy.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = /^[1-9]\d*$/.test(String(query?.personId || ""))
|
||||
? String(query.personId)
|
||||
: "";
|
||||
resetForm();
|
||||
if (valid.value) {
|
||||
loadRecords();
|
||||
loadPersonOptions();
|
||||
loadGrowthTypes();
|
||||
}
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadRecords();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
recordListRequestController.abort();
|
||||
growthEditorDetailRequestController.abort();
|
||||
growthMediaUploadRequestController.abort();
|
||||
growthRecordSaveRequestController.abort();
|
||||
personOptionsController.abort();
|
||||
growthTypeRequestController.abort();
|
||||
growthRecordDeleteController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.record-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.person-option-note {
|
||||
display: block;
|
||||
margin: 8rpx 8rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
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-field > text,
|
||||
.error {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.record-filters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12rpx; }
|
||||
.record-filters picker > view {
|
||||
@include adaptive-records-field;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
min-height: 78rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14rpx 18rpx;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.record-filters text { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); overflow-wrap: anywhere; }
|
||||
.record-filters text:last-child { color: $brand-red; text-align: right; }
|
||||
.filter-empty { display: block; padding: 34rpx 20rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); text-align: center; }
|
||||
.record-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.record-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,623 @@
|
||||
<template>
|
||||
<view class="life-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="人生大事"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ editingEvent ? "修改人生大事" : "新建人生大事" }}</text>
|
||||
<text class="form-copy">红色 * 为必填项;请按实际情况选择事件类型和日期。</text>
|
||||
<view class="field field--picker">
|
||||
<text><text class="required-mark">*</text>事件类型</text>
|
||||
<picker :range="eventTypeLabels" :value="eventTypeIndex" @change="selectEventType">
|
||||
<view :class="{ placeholder: !form.eventType }">{{ eventTypeLabel || "请选择" }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text><text class="required-mark">*</text>事件标题</text>
|
||||
<input v-model="form.eventTitle" placeholder="请输入事件标题" @input="error = ''" />
|
||||
</view>
|
||||
<view class="field field--picker">
|
||||
<text><text class="required-mark">*</text>发生日期</text>
|
||||
<picker mode="date" :value="form.eventDate" @change="selectEventDate">
|
||||
<view :class="{ placeholder: !form.eventDate }">{{ form.eventDate || "请选择" }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field field--picker">
|
||||
<text><text class="required-mark">*</text>日期填写方式</text>
|
||||
<picker :range="datePrecisionLabels" :value="datePrecisionIndex" @change="selectDatePrecision">
|
||||
<view :class="{ placeholder: !form.datePrecision }">{{ datePrecisionLabel || "请选择" }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field field--textarea">
|
||||
<text>事件内容</text>
|
||||
<textarea v-model="form.eventContent" auto-height placeholder="记录这段家族记忆" @input="error = ''" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text>发生地点</text>
|
||||
<input v-model="form.eventPlace" placeholder="请输入地点" @input="error = ''" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text>资料来源</text>
|
||||
<input v-model="form.sourceDescription" placeholder="例如:家谱手稿" @input="error = ''" />
|
||||
</view>
|
||||
<view class="upload-field">
|
||||
<view>
|
||||
<text>相关图片</text>
|
||||
<text>图片上传成功后,会随这条记录一起保存。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">
|
||||
已上传:{{ receipt.fileName || "图片" }}
|
||||
</text>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions">
|
||||
<AppButton type="secondary" label="取消" @click="cancelCreate" />
|
||||
<AppButton :disabled="submitting || uploading" :label="submitting ? '正在保存' : editingEvent ? '保存修改' : '提交人生大事'" @click="saveLifeEvent" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card">
|
||||
<text>暂时无法打开人生大事</text>
|
||||
<text>未获取到当前人物,请从人物档案重新进入。</text>
|
||||
<AppButton block label="返回上一页" @click="requestBack" />
|
||||
</view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取人生大事" />
|
||||
</view>
|
||||
<view v-else-if="listState === 'error'" class="state-card">
|
||||
<text>暂时无法读取人生大事</text>
|
||||
<text>请检查网络后重新加载。</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadEvents" />
|
||||
</view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card">
|
||||
<text>还没有人生大事</text>
|
||||
<AppButton block label="新建人生大事" @click="openCreate" />
|
||||
</view>
|
||||
<view v-else class="event-list">
|
||||
<text v-if="saveNotice" class="save-notice">人生大事已保存。</text>
|
||||
<view v-for="item in events" :key="item.id" class="event-card">
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ eventTypeText(item.type) }} · {{ formatEventDate(item) }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
<text v-if="item.place">地点:{{ item.place }}</text>
|
||||
<text v-if="item.sourceDescription">来源:{{ item.sourceDescription }}</text>
|
||||
<text v-if="item.mediaFiles.length">已关联 {{ item.mediaFiles.length }} 张图片</text>
|
||||
<view v-if="item.canEdit || item.canDelete" class="event-card__actions">
|
||||
<AppButton v-if="item.canEdit" compact type="secondary" label="修改" @click="openEdit(item)" />
|
||||
<AppButton v-if="item.canDelete" compact type="secondary" label="删除" @click="requestDelete(item)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃人生大事?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteVisible"
|
||||
eyebrow="删除人生大事"
|
||||
title="确认删除这条记录?"
|
||||
message="删除后无法恢复,请确认不再需要这条记录。"
|
||||
:confirm-text="deleting ? '正在删除' : '确认删除'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="deleteVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, 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 {
|
||||
LIFE_EVENT_DATE_PRECISION_OPTIONS as datePrecisionOptions,
|
||||
LIFE_EVENT_TYPE_OPTIONS as eventTypeOptions
|
||||
} from "@/services/api/life-record-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const events = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const mediaReceipts = ref([]);
|
||||
const editingEvent = ref(null);
|
||||
const deleteTarget = ref(null);
|
||||
const deleteVisible = ref(false);
|
||||
const deleting = ref(false);
|
||||
const form = reactive({
|
||||
eventType: "",
|
||||
eventTitle: "",
|
||||
eventDate: "",
|
||||
datePrecision: "",
|
||||
eventContent: "",
|
||||
eventPlace: "",
|
||||
sourceDescription: "",
|
||||
});
|
||||
const eventListController = createRequestController();
|
||||
const eventImageUploadController = createRequestController();
|
||||
const eventSaveController = createRequestController();
|
||||
const eventDeleteController = createRequestController();
|
||||
const lifeEventCreateGuard = createNonIdempotentWriteGuard();
|
||||
let isPageActive = true;
|
||||
const valid = computed(
|
||||
() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(personId.value),
|
||||
);
|
||||
const eventTypeLabels = computed(() => eventTypeOptions.map((item) => item.label));
|
||||
const datePrecisionLabels = computed(() => datePrecisionOptions.map((item) => item.label));
|
||||
const eventTypeIndex = computed(() => Math.max(0, eventTypeOptions.findIndex((item) => item.value === form.eventType)));
|
||||
const datePrecisionIndex = computed(() => Math.max(0, datePrecisionOptions.findIndex((item) => item.value === form.datePrecision)));
|
||||
const eventTypeLabel = computed(() => eventTypeText(form.eventType));
|
||||
const datePrecisionLabel = computed(() => datePrecisionText(form.datePrecision));
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId));
|
||||
const dirty = computed(
|
||||
() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
|
||||
const eventTypeText = (value) =>
|
||||
eventTypeOptions.find((item) => item.value === value)?.label || "未分类";
|
||||
const datePrecisionText = (value) =>
|
||||
datePrecisionOptions.find((item) => item.value === value)?.label || "";
|
||||
const formatEventDate = (event) => {
|
||||
if (event.datePrecision === "YEAR") return event.date.slice(0, 4);
|
||||
if (event.datePrecision === "MONTH") return event.date.slice(0, 7);
|
||||
return event.date;
|
||||
};
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
eventType: "",
|
||||
eventTitle: "",
|
||||
eventDate: "",
|
||||
datePrecision: "",
|
||||
eventContent: "",
|
||||
eventPlace: "",
|
||||
sourceDescription: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingEvent.value = null;
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
const loadEvents = async () => {
|
||||
if (!valid.value) return;
|
||||
eventListController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getLifeEvents(genealogyId.value, personId.value, {
|
||||
requestController: eventListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
events.value = rows;
|
||||
listState.value = rows.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!isPageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const openEdit = (item) => {
|
||||
if (!item?.canEdit || submitting.value || uploading.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
editingEvent.value = item;
|
||||
Object.assign(form, {
|
||||
eventType: item.type,
|
||||
eventTitle: item.title,
|
||||
eventDate: item.date,
|
||||
datePrecision: item.datePrecision,
|
||||
eventContent: item.content,
|
||||
eventPlace: item.place,
|
||||
sourceDescription: item.sourceDescription,
|
||||
});
|
||||
mediaReceipts.value = item.mediaFiles.map((file) => ({ ossId: file.ossId, fileName: file.fileName }));
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectEventType = (event) => {
|
||||
form.eventType = eventTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectEventDate = (event) => {
|
||||
form.eventDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectDatePrecision = (event) => {
|
||||
form.datePrecision = datePrecisionOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const uploadReceipt = await pickAndUploadImage({
|
||||
requestController: eventImageUploadController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
|
||||
} catch (cause) {
|
||||
if (
|
||||
isPageActive &&
|
||||
!isImagePickCancelled(cause) &&
|
||||
!isRequestCancelled(cause)
|
||||
) {
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveLifeEvent = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.eventType || !form.eventTitle.trim() || !form.eventDate || !form.datePrecision) {
|
||||
error.value = "请填写事件类型、标题和发生日期,并选择日期填写方式。";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
eventType: form.eventType,
|
||||
eventTitle: form.eventTitle,
|
||||
eventDate: form.eventDate,
|
||||
datePrecision: form.datePrecision,
|
||||
eventContent: form.eventContent,
|
||||
eventPlace: form.eventPlace,
|
||||
sourceDescription: form.sourceDescription,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
};
|
||||
const createAttempt = editingEvent.value
|
||||
? null
|
||||
: lifeEventCreateGuard.begin(payload);
|
||||
if (!editingEvent.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回人生大事列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editingEvent.value) {
|
||||
await lifeRecordApi.updateLifeEvent(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
editingEvent.value.id,
|
||||
payload,
|
||||
{ requestController: eventSaveController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.createLifeEvent(genealogyId.value, personId.value, payload, {
|
||||
requestController: eventSaveController,
|
||||
});
|
||||
}
|
||||
if (!isPageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadEvents();
|
||||
} catch (cause) {
|
||||
if (!isPageActive) return;
|
||||
if (
|
||||
!editingEvent.value &&
|
||||
lifeEventCreateGuard.recordFailure(createAttempt, cause)
|
||||
) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回人生大事列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "人生大事提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDelete = (item) => {
|
||||
if (!item?.canDelete || deleting.value) return;
|
||||
deleteTarget.value = item;
|
||||
deleteVisible.value = true;
|
||||
};
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget.value?.canDelete || deleting.value) return;
|
||||
const event = deleteTarget.value;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await lifeRecordApi.deleteLifeEvent(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
event.id,
|
||||
{ requestController: eventDeleteController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
deleteVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadEvents();
|
||||
} catch (cause) {
|
||||
if (isPageActive && !isRequestCancelled(cause)) {
|
||||
error.value = getRequestErrorMessage(cause, "这条人生大事删除失败,请稍后重试。");
|
||||
deleteVisible.value = false;
|
||||
}
|
||||
} finally {
|
||||
if (isPageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = String(query?.personId || "");
|
||||
resetForm();
|
||||
if (valid.value) loadEvents();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading") loadEvents();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
eventListController.abort();
|
||||
eventImageUploadController.abort();
|
||||
eventSaveController.abort();
|
||||
eventDeleteController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.life-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.event-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
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-field > text,
|
||||
.error {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.event-card {
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.event-card text {
|
||||
display: block;
|
||||
}
|
||||
.event-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.event-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.event-card__actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.event-card__actions .app-button { width: 154rpx; min-height: 66rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,734 @@
|
||||
<template>
|
||||
<view class="memo-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="家族备忘"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? '编辑家族备忘' : '新建家族备忘' }}</text>
|
||||
<text class="form-copy">{{ isEdit ? '原有关联图片、完成状态和排序会随本次保存保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>备忘标题</text
|
||||
><input
|
||||
v-model="form.memoTitle"
|
||||
maxlength="40"
|
||||
placeholder="请输入备忘标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"
|
||||
><view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><text>{{ form.remindDate || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><text>{{ form.remindClock || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<view class="field field--textarea"
|
||||
><text>备忘内容</text
|
||||
><textarea
|
||||
v-model="form.memoContent"
|
||||
auto-height
|
||||
maxlength="1200"
|
||||
placeholder="记录需要提醒的事情"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片上传成功后,会随备忘一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交备忘'"
|
||||
@click="saveMemo"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>暂时无法打开家族备忘</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取家族备忘"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取家族备忘</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMemos"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有家族备忘</text
|
||||
><AppButton block label="新建备忘" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="memo-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>备忘已保存。</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
|
||||
<view class="memo-card__copy">
|
||||
<text>{{ item.title }}</text>
|
||||
<text v-if="item.remindTime">{{ item.remindTime }}</text>
|
||||
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
|
||||
</view>
|
||||
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEdit(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteMemo(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="备忘详情"
|
||||
:title="detailTarget?.title || '家族备忘'"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="closeMemoDetail"
|
||||
@cancel="closeMemoDetail"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整备忘" />
|
||||
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
|
||||
<template v-else>
|
||||
<text>提醒时间:{{ detailTarget?.remindTime || "未设置" }}</text>
|
||||
<text>完成状态:{{ detailTarget?.completed === "1" ? "已完成" : "未完成" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写备忘内容" }}</text>
|
||||
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
|
||||
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" aria-label="查看备忘图片" @click="previewDetailMedia(file)" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃家族备忘?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条家族备忘?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留备忘"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteMemo"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, 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 { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const memos = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const editingMemo = ref(null);
|
||||
const formBaseline = ref("");
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const detailTarget = ref(null);
|
||||
const detailState = ref("idle");
|
||||
const detailError = ref("");
|
||||
const pendingMemoId = ref("");
|
||||
const form = reactive({
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const memoListRequestController = createRequestController();
|
||||
const memoEditorDetailRequestController = createRequestController();
|
||||
const memoMediaUploadRequestController = createRequestController();
|
||||
const memoSaveRequestController = createRequestController();
|
||||
const memoDeletionRequestController = createRequestController();
|
||||
const memoDetailRequestController = createRequestController();
|
||||
const memoCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingMemo.value));
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingMemo.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadMemos = async () => {
|
||||
if (!valid.value) return;
|
||||
memoListRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getMemos(genealogyId.value, {
|
||||
requestController: memoListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memos.value = rows;
|
||||
listState.value = memos.value.length ? "ready" : "empty";
|
||||
if (pendingMemoId.value) {
|
||||
const target = memos.value.find((item) => item.id === pendingMemoId.value);
|
||||
pendingMemoId.value = "";
|
||||
if (target) void openMemoDetail(target);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const splitReminderTime = (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 openEdit = async (memo) => {
|
||||
if (!memo?.canEdit || !valid.value || submitting.value || uploading.value) return;
|
||||
memoEditorDetailRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getMemoDetail(genealogyId.value, memo.id, {
|
||||
requestController: memoEditorDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!["0", "1"].includes(detail.completed) ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("这条备忘的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const reminder = splitReminderTime(detail.remindTime);
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
memoTitle: detail.title,
|
||||
remindDate: reminder.date,
|
||||
remindClock: reminder.clock,
|
||||
memoContent: detail.content,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingMemo.value = {
|
||||
id: detail.id,
|
||||
completed: detail.completed,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
view.value = "form";
|
||||
listState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openMemoDetail = async (memo) => {
|
||||
if (!memo?.id || detailState.value === "loading") return;
|
||||
detailTarget.value = memo;
|
||||
detailState.value = "loading";
|
||||
detailError.value = "";
|
||||
memoDetailRequestController.abort();
|
||||
try {
|
||||
const loadedMemo = await lifeRecordApi.getMemoDetail(
|
||||
genealogyId.value,
|
||||
memo.id,
|
||||
{ requestController: memoDetailRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
detailTarget.value = loadedMemo;
|
||||
detailState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
detailState.value = "error";
|
||||
detailError.value = getRequestErrorMessage(cause, "完整备忘读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const closeMemoDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const previewDetailMedia = (file) => {
|
||||
const urls = detailTarget.value?.mediaFiles?.map((item) => item.accessUrl).filter(Boolean) || [];
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const uploadReceipt = await pickAndUploadImage({
|
||||
requestController: memoMediaUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
|
||||
} catch (cause) {
|
||||
if (
|
||||
pageActive &&
|
||||
!isImagePickCancelled(cause) &&
|
||||
!isRequestCancelled(cause)
|
||||
)
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveMemo = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.memoTitle.trim()) {
|
||||
error.value = "请填写备忘标题";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
memoTitle: form.memoTitle,
|
||||
remindTime: remindTime.value,
|
||||
memoContent: form.memoContent,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingMemo.value
|
||||
? {
|
||||
completed: editingMemo.value.completed,
|
||||
sortOrder: editingMemo.value.sortOrder,
|
||||
status: editingMemo.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingMemo.value ? null : memoCreateGuard.begin(payload);
|
||||
if (!editingMemo.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回备忘列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editingMemo.value) {
|
||||
await lifeRecordApi.updateMemo(genealogyId.value, editingMemo.value.id, payload, {
|
||||
requestController: memoSaveRequestController,
|
||||
});
|
||||
} else {
|
||||
await lifeRecordApi.createMemo(genealogyId.value, payload, {
|
||||
requestController: memoSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMemos();
|
||||
} catch (cause) {
|
||||
if (!pageActive) return;
|
||||
if (!editingMemo.value && memoCreateGuard.recordFailure(createAttempt, cause)) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回备忘列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "备忘提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteMemo = (memo) => {
|
||||
if (!memo?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = memo;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteMemo = async () => {
|
||||
const memo = deleteTarget.value;
|
||||
if (!memo?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteMemo(genealogyId.value, memo.id, {
|
||||
requestController: memoDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadMemos();
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
deleteError.value = getRequestErrorMessage(cause, "备忘删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTarget.value
|
||||
? (closeMemoDetail(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
pendingMemoId.value = /^[1-9]\d*$/.test(String(query?.memoId || "")) ? String(query.memoId) : "";
|
||||
if (valid.value) loadMemos();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMemos();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
memoListRequestController.abort();
|
||||
memoEditorDetailRequestController.abort();
|
||||
memoMediaUploadRequestController.abort();
|
||||
memoSaveRequestController.abort();
|
||||
memoDeletionRequestController.abort();
|
||||
memoDetailRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.memo-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.memo-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 142rpx minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12rpx 20rpx;
|
||||
min-height: 82rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.field > text,
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field > text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.field--textarea {
|
||||
align-items: start;
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 116rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
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-field > text {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.memo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.memo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 18rpx;
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.memo-card__copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.memo-card__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.memo-card__actions .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.memo-card text {
|
||||
display: block;
|
||||
}
|
||||
.memo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.memo-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
|
||||
.detail-error { color: $brand-red !important; }
|
||||
.detail-media { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16rpx; gap: 10rpx; }
|
||||
.detail-media image { width: 100%; height: 150rpx; border-radius: 8rpx; background: rgba(128, 89, 49, .12); }
|
||||
</style>
|
||||
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<view class="merit-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="功德记录"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>{{ isEdit ? "编辑功德记录" : "新建功德记录" }}</text>
|
||||
<text class="form-copy">{{ isEdit ? "原有设置会保留。" : "红色 * 为必填项,其余内容可按需补充。" }}</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field"
|
||||
><text
|
||||
><text v-if="field.required" class="required-mark">*</text
|
||||
>{{ field.label }}</text
|
||||
><textarea
|
||||
v-if="field.key === 'content'"
|
||||
v-model="form[field.key]"
|
||||
auto-height
|
||||
:placeholder="`请输入${field.label}`"
|
||||
@input="error = ''" /><input
|
||||
v-else
|
||||
v-model="form[field.key]"
|
||||
:type="field.inputType || 'text'"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--picker"
|
||||
><text>功德类型</text
|
||||
><picker
|
||||
:range="meritTypeOptions.map((item) => item.label)"
|
||||
:value="meritTypeIndex"
|
||||
@change="selectMeritType"
|
||||
><view :class="{ placeholder: !form.type }">{{
|
||||
meritTypeLabel || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker mode="date" :value="form.meritDate" @change="selectMeritDate"
|
||||
><view :class="{ placeholder: !form.meritDate }">{{
|
||||
form.meritDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.meritClock"
|
||||
@change="selectMeritClock"
|
||||
><view :class="{ placeholder: !form.meritClock }">{{
|
||||
form.meritClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting"
|
||||
:label="submitting ? '正在提交' : isEdit ? '保存修改' : '提交功德记录'"
|
||||
@click="saveMeritRecord"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>暂时无法打开功德记录</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取功德记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取功德记录</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMerits"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有功德记录</text
|
||||
><AppButton block label="新建功德记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="merit-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>{{ saveNotice }}</text
|
||||
>
|
||||
<text class="merit-summary"
|
||||
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text
|
||||
>
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMeritDetail(item)">
|
||||
<view>
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
</view>
|
||||
<view class="merit-card__amount">
|
||||
<text>¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEditMerit(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteMerit(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="功德详情"
|
||||
:title="detailTarget?.title || '功德记录'"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="closeMeritDetail"
|
||||
@cancel="closeMeritDetail"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
|
||||
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
|
||||
<template v-else>
|
||||
<text>捐赠人:{{ detailTarget?.donor || "未署名" }}</text>
|
||||
<text>功德类型:{{ detailTarget?.typeLabel || "未填写" }}</text>
|
||||
<text>记录时间:{{ detailTarget?.time || "未填写" }}</text>
|
||||
<text>金额:¥{{ detailTarget?.amount || "0.00" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃功德记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这笔功德记录?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteMerit"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, 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 {
|
||||
MERIT_TYPE_OPTIONS as meritTypeOptions
|
||||
} from "@/services/api/life-record-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const merits = ref([]);
|
||||
const saveNotice = ref("");
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const editingMerit = ref(null);
|
||||
const detailTarget = ref(null);
|
||||
const detailState = ref("idle");
|
||||
const detailError = ref("");
|
||||
const formBaseline = ref("");
|
||||
const form = reactive({
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
});
|
||||
const fields = [
|
||||
{ key: "donor", label: "捐赠人", required: true },
|
||||
{ key: "title", label: "功德标题", required: true },
|
||||
{ key: "amount", inputType: "digit", label: "金额" },
|
||||
{ key: "content", label: "记录内容" },
|
||||
];
|
||||
const meritListRequestController = createRequestController();
|
||||
const meritEditorDetailRequestController = createRequestController();
|
||||
const meritSaveRequestController = createRequestController();
|
||||
const meritDeletionRequestController = createRequestController();
|
||||
const meritDetailRequestController = createRequestController();
|
||||
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingMerit.value));
|
||||
const formSnapshot = computed(() => JSON.stringify(form));
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()),
|
||||
);
|
||||
const meritTime = computed(() =>
|
||||
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
|
||||
);
|
||||
const meritTypeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
meritTypeOptions.findIndex((item) => item.value === form.type),
|
||||
),
|
||||
);
|
||||
const meritTypeLabel = computed(
|
||||
() => meritTypeOptions.find((item) => item.value === form.type)?.label || "",
|
||||
);
|
||||
const addCurrencyAmounts = (left, right) => {
|
||||
const [leftWhole, leftFraction] = left.split(".");
|
||||
const [rightWhole, rightFraction] = right.split(".");
|
||||
let carry = Number(leftFraction) + Number(rightFraction);
|
||||
const fraction = String(carry % 100).padStart(2, "0");
|
||||
carry = Math.floor(carry / 100);
|
||||
const digits = [];
|
||||
let leftIndex = leftWhole.length - 1;
|
||||
let rightIndex = rightWhole.length - 1;
|
||||
while (leftIndex >= 0 || rightIndex >= 0 || carry) {
|
||||
const sum = (Number(leftWhole[leftIndex--] || 0) + Number(rightWhole[rightIndex--] || 0) + carry);
|
||||
digits.push(sum % 10);
|
||||
carry = Math.floor(sum / 10);
|
||||
}
|
||||
return `${digits.reverse().join("")}.${fraction}`;
|
||||
};
|
||||
const totalAmount = computed(() =>
|
||||
merits.value.reduce((total, item) => addCurrencyAmounts(total, item.amount), "0.00"),
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
});
|
||||
editingMerit.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
};
|
||||
const loadMerits = async () => {
|
||||
if (!valid.value) return;
|
||||
meritListRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getMeritRecords(genealogyId.value, {
|
||||
requestController: meritListRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
merits.value = rows;
|
||||
listState.value = merits.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = "";
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const splitMeritTime = (value) => {
|
||||
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 openEditMerit = async (merit) => {
|
||||
if (!merit?.canEdit || !valid.value || submitting.value) return;
|
||||
meritEditorDetailRequestController.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getMeritRecordDetail(genealogyId.value, merit.id, {
|
||||
requestController: meritEditorDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!detail.canEdit ||
|
||||
!detail.donor ||
|
||||
!detail.title ||
|
||||
!meritTypeOptions.some((item) => item.value === detail.type) ||
|
||||
!detail.amount ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
) {
|
||||
throw new Error("这条功德记录的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const timeParts = splitMeritTime(detail.time);
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
donor: detail.donor,
|
||||
title: detail.title,
|
||||
type: detail.type,
|
||||
amount: detail.amount,
|
||||
meritDate: timeParts.date,
|
||||
meritClock: timeParts.clock,
|
||||
content: detail.content,
|
||||
});
|
||||
editingMerit.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
listState.value = "ready";
|
||||
view.value = "form";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openMeritDetail = async (merit) => {
|
||||
if (!merit?.id || detailState.value === "loading") return;
|
||||
detailTarget.value = merit;
|
||||
detailState.value = "loading";
|
||||
detailError.value = "";
|
||||
meritDetailRequestController.abort();
|
||||
try {
|
||||
const loadedMerit = await lifeRecordApi.getMeritRecordDetail(genealogyId.value, merit.id, {
|
||||
requestController: meritDetailRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
detailTarget.value = loadedMerit;
|
||||
detailState.value = "ready";
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
detailState.value = "error";
|
||||
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const closeMeritDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectMeritDate = (event) => {
|
||||
form.meritDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritClock = (event) => {
|
||||
form.meritClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritType = (event) => {
|
||||
form.type = meritTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const saveMeritRecord = async () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
const meritTitle = form.title.trim();
|
||||
const amountText = form.amount.trim();
|
||||
if (!donorName || !meritTitle) {
|
||||
error.value = !donorName ? "请填写捐赠人" : "请填写功德标题";
|
||||
return;
|
||||
}
|
||||
if (amountText && !Number.isFinite(Number(amountText))) {
|
||||
error.value = "金额必须是数字";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
donorName,
|
||||
meritTitle,
|
||||
meritType: form.type,
|
||||
meritContent: form.content,
|
||||
meritTime: meritTime.value,
|
||||
...(amountText ? { amount: Number(amountText) } : {}),
|
||||
...(editingMerit.value
|
||||
? {
|
||||
sortOrder: editingMerit.value.sortOrder,
|
||||
status: editingMerit.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingMerit.value
|
||||
? null
|
||||
: meritRecordCreateGuard.begin(payload);
|
||||
if (!editingMerit.value && createAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先返回功德记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const wasEditing = Boolean(editingMerit.value);
|
||||
if (editingMerit.value) {
|
||||
await lifeRecordApi.updateMeritRecord(genealogyId.value, editingMerit.value.id, payload, {
|
||||
requestController: meritSaveRequestController,
|
||||
});
|
||||
} else {
|
||||
await lifeRecordApi.createMeritRecord(genealogyId.value, payload, {
|
||||
requestController: meritSaveRequestController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = wasEditing
|
||||
? "功德记录已更新。"
|
||||
: "功德记录已保存。";
|
||||
await loadMerits();
|
||||
} catch (cause) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!editingMerit.value &&
|
||||
meritRecordCreateGuard.recordFailure(createAttempt, cause)
|
||||
) {
|
||||
error.value =
|
||||
"提交结果暂时无法确认,请先返回功德记录列表检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "功德记录提交失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestDeleteMerit = (merit) => {
|
||||
if (!merit?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = merit;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteMerit = async () => {
|
||||
const merit = deleteTarget.value;
|
||||
if (!merit?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteMeritRecord(genealogyId.value, merit.id, {
|
||||
requestController: meritDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadMerits();
|
||||
} catch (cause) {
|
||||
if (!pageActive || isRequestCancelled(cause)) return;
|
||||
deleteError.value = getRequestErrorMessage(cause, "功德记录删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
detailTarget.value
|
||||
? (closeMeritDetail(), true)
|
||||
: view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMerits();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMerits();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
meritListRequestController.abort();
|
||||
meritEditorDetailRequestController.abort();
|
||||
meritSaveRequestController.abort();
|
||||
meritDeletionRequestController.abort();
|
||||
meritDetailRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.merit-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.merit-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.merit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-summary {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.merit-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.merit-card text {
|
||||
display: block;
|
||||
}
|
||||
.merit-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card__amount {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.merit-card__amount > text {
|
||||
margin-right: auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-card__amount .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
|
||||
.detail-error { color: $brand-red !important; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:R-01;用途:人物录列表、搜索、空态与失败状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="people-page"
|
||||
@@ -81,14 +80,14 @@
|
||||
peopleState === "empty"
|
||||
? "还没有人物记录"
|
||||
: peopleState === "invalid"
|
||||
? "人物录入口无效"
|
||||
? "暂时无法打开人物录"
|
||||
: "人物录暂不可用"
|
||||
}}</text>
|
||||
<text>{{
|
||||
peopleState === "empty"
|
||||
? "从第一位值得铭记的家人开始建立人物录。"
|
||||
: peopleState === "invalid"
|
||||
? "没有找到可访问的成员家谱,页面不会展示其他家谱人物。"
|
||||
? "未找到可访问的家谱,请返回后重新进入。"
|
||||
: "请稍后重新进入,已有档案不会受到影响。"
|
||||
}}</text>
|
||||
</view>
|
||||
@@ -112,11 +111,11 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const people = ref([]);
|
||||
@@ -126,7 +125,7 @@ const keyword = ref("");
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const loadingMore = ref(false);
|
||||
const peopleRequestController = createRequestController();
|
||||
const peopleListRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value));
|
||||
const hasMore = computed(() => people.value.length < total.value);
|
||||
@@ -152,14 +151,14 @@ const loadPeople = async ({ append = false } = {}) => {
|
||||
if (append) loadingMore.value = true;
|
||||
else peopleState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPersonPage(
|
||||
const personPage = await lineageApi.getPersonPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
|
||||
{ requestController: peopleRequestController },
|
||||
{ requestController: peopleListRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return;
|
||||
people.value = append ? [...people.value, ...result.rows] : result.rows;
|
||||
total.value = result.total;
|
||||
people.value = append ? [...people.value, ...personPage.rows] : personPage.rows;
|
||||
total.value = personPage.total;
|
||||
peopleState.value = people.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -197,7 +196,7 @@ const handleStateAction = () => {
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
if (!hasValidContext.value) {
|
||||
people.value = [];
|
||||
peopleState.value = "invalid";
|
||||
return;
|
||||
@@ -206,7 +205,7 @@ onLoad((query) => {
|
||||
});
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
peopleRequestController.abort();
|
||||
peopleListRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -302,7 +301,7 @@ onUnload(() => {
|
||||
.people-result-empty,
|
||||
.people-state-card {
|
||||
margin-top: 38rpx;
|
||||
background: url("/static/assets/modules/records/transparent/r01-person-name-card.png")
|
||||
background: url("/static/assets/modules/records/transparent/person-name-card.png")
|
||||
top center / 100% 220rpx no-repeat;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:R-02;用途:人物录详情与不写库的人物资料预览。 -->
|
||||
<template>
|
||||
<view
|
||||
class="person-detail-page"
|
||||
@@ -50,9 +49,12 @@
|
||||
label="成长日志"
|
||||
@click="toGrowthJournal"
|
||||
/>
|
||||
<view class="person-related-unavailable"
|
||||
><text>人生大事</text><text>暂未开放</text></view
|
||||
>
|
||||
<AppButton
|
||||
type="secondary"
|
||||
block
|
||||
label="人生大事"
|
||||
@click="toLifeEvents"
|
||||
/>
|
||||
</view>
|
||||
<view class="person-edit-action" @click="toMemberProfile"
|
||||
><AppButton block label="查看成员档案"
|
||||
@@ -90,21 +92,21 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const personState = ref("loading");
|
||||
const personRequestController = createRequestController();
|
||||
const personDetailRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const person = reactive({
|
||||
id: "",
|
||||
@@ -114,12 +116,16 @@ const person = reactive({
|
||||
generation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
sexLabel: "",
|
||||
personStatus: "",
|
||||
personStatusLabel: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthLunarLabel: "",
|
||||
birthplace: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathLunarLabel: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
spouseNames: "",
|
||||
@@ -129,13 +135,13 @@ const person = reactive({
|
||||
const detailSections = computed(() => [
|
||||
{ title: "别名", copy: person.aliasName },
|
||||
{ title: "字辈", copy: person.generationName },
|
||||
{ title: "性别(字典值)", copy: person.sex },
|
||||
{ title: "人物状态(字典值)", copy: person.personStatus },
|
||||
{ title: "性别", copy: person.sexLabel },
|
||||
{ title: "人物状态", copy: person.personStatusLabel },
|
||||
{ title: "出生日期", copy: person.birthDate },
|
||||
{ title: "出生农历", copy: person.birthLunar },
|
||||
{ title: "出生农历", copy: person.birthLunarLabel },
|
||||
{ title: "出生地", copy: person.birthplace },
|
||||
{ title: "逝世日期", copy: person.deathDate },
|
||||
{ title: "逝世农历", copy: person.deathLunar },
|
||||
{ title: "逝世农历", copy: person.deathLunarLabel },
|
||||
{ title: "逝世地", copy: person.deathPlace },
|
||||
{ title: "安葬地", copy: person.burialPlace },
|
||||
{ title: "配偶", copy: person.spouseNames },
|
||||
@@ -156,6 +162,14 @@ const toGrowthJournal = () =>
|
||||
"R02",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const toLifeEvents = () =>
|
||||
person.id
|
||||
? openPage(
|
||||
"R09",
|
||||
{ genealogyId: genealogyId.value, personId: personId.value },
|
||||
"R02",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const toMemberProfile = () =>
|
||||
person.id
|
||||
? openPage(
|
||||
@@ -169,11 +183,11 @@ const loadPerson = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
personState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: personRequestController,
|
||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: personDetailRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
Object.assign(person, result);
|
||||
Object.assign(person, personDetail);
|
||||
personState.value = "detail";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -187,8 +201,7 @@ onLoad((query) => {
|
||||
if (
|
||||
query.mode !== "view" ||
|
||||
!genealogyId.value ||
|
||||
!personId.value ||
|
||||
query.state === "error"
|
||||
!personId.value
|
||||
) {
|
||||
personState.value = "error";
|
||||
return;
|
||||
@@ -199,7 +212,7 @@ onLoad((query) => {
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
personRequestController.abort();
|
||||
personDetailRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -288,26 +301,6 @@ onUnload(() => {
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.person-related-unavailable {
|
||||
display: flex;
|
||||
min-height: 88rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 252, 245, 0.38);
|
||||
color: $ink-muted;
|
||||
}
|
||||
.person-related-unavailable text:first-child {
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.person-related-unavailable text:last-child {
|
||||
margin-top: 4rpx;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
}
|
||||
.person-state-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1,192 +0,0 @@
|
||||
<!-- 页面编号:R-03;用途:读取并展示当前家谱的亲友往来记录。 -->
|
||||
<template>
|
||||
<view class="gift-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="贺礼簿"
|
||||
:action="valid ? '新建' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@action="createRelative"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>贺礼簿入口无效</text
|
||||
><AppButton block label="返回上一页" @click="returnToFamily"
|
||||
/></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取亲友往来"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取亲友往来</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>还没有亲友往来记录</text
|
||||
><AppButton block label="新建往来记录" @click="createRelative"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<view v-for="item in records" :key="item.id" class="record-card">
|
||||
<view
|
||||
><text>{{ item.name }}</text
|
||||
><text
|
||||
>{{ item.relation
|
||||
}}{{ item.event ? ` · ${item.event}` : "" }}</text
|
||||
><text v-if="item.time || item.content">{{
|
||||
item.time || item.content
|
||||
}}</text></view
|
||||
>
|
||||
<text v-if="item.amount">¥{{ item.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const records = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getRelativeRecords(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
records.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.relativeId || ""),
|
||||
name: String(item.relativeName || "未命名亲友"),
|
||||
relation: String(item.relationName || ""),
|
||||
event: String(item.eventName || ""),
|
||||
time: String(item.eventTime || ""),
|
||||
amount:
|
||||
item.giftAmount == null || item.giftAmount === ""
|
||||
? ""
|
||||
: String(item.giftAmount),
|
||||
content: String(item.recordContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = records.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createRelative = () =>
|
||||
valid.value
|
||||
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
||||
: Promise.resolve(false);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadRecords();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadRecords();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.gift-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.record-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card > text {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<view class="ritual-detail-page"
|
||||
><ModulePageBackground module="records" /><view class="page-header"
|
||||
><PageHeader title="礼仪详情" custom-back @back="returnToList" /></view
|
||||
><view class="page-content"
|
||||
><view v-if="!valid" class="state-card"
|
||||
><text>礼仪入口无效</text
|
||||
><text>未获取到可查看的礼仪活动标识,请从活动列表重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="returnToList" /></view
|
||||
><view v-else-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取礼仪详情" /></view
|
||||
><view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取礼仪详情</text
|
||||
><text>请检查网络后重新加载。</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadDetail" /></view
|
||||
><view v-else class="detail-card"
|
||||
><text>{{ detail.title }}</text
|
||||
><text
|
||||
>{{ detail.type }}{{ detail.time ? ` · ${detail.time}` : "" }}</text
|
||||
><text v-if="detail.location">地点:{{ detail.location }}</text
|
||||
><text v-if="detail.description">{{ detail.description }}</text
|
||||
><text>已有 {{ detail.giftCount }} 笔献礼</text></view
|
||||
></view
|
||||
></view
|
||||
>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
const genealogyId = ref("");
|
||||
const ceremonyId = ref("");
|
||||
const state = ref("loading");
|
||||
const detail = ref({
|
||||
title: "",
|
||||
type: "",
|
||||
time: "",
|
||||
location: "",
|
||||
description: "",
|
||||
giftCount: 0,
|
||||
});
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value),
|
||||
);
|
||||
const loadDetail = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const item = await appApi.getCeremonyDetail(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!active) return;
|
||||
detail.value = {
|
||||
title: String(item.ceremonyTitle || "未命名活动"),
|
||||
type: String(item.ceremonyType || ""),
|
||||
time: String(item.ceremonyTime || ""),
|
||||
location: String(item.location || item.locationAddress || ""),
|
||||
description: String(item.ceremonyDesc || ""),
|
||||
giftCount: Number.isSafeInteger(item.giftCount) ? item.giftCount : 0,
|
||||
};
|
||||
state.value = "ready";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
ceremonyId.value = String(query?.ceremonyId || "");
|
||||
if (valid.value) loadDetail();
|
||||
else state.value = "invalid";
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
const returnToList = () =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.ritual-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.detail-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.detail-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
padding: 42rpx;
|
||||
}
|
||||
.detail-card text {
|
||||
display: block;
|
||||
}
|
||||
.detail-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-card text:not(:first-child) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,602 +0,0 @@
|
||||
<!-- 页面编号:R-08;用途:读取、新建当前家谱的成长记录。 -->
|
||||
<template>
|
||||
<view class="record-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="成长日志"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建成长记录</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view class="field field--context"
|
||||
><text>关联人物</text
|
||||
><view
|
||||
><text>{{ personName }}</text
|
||||
><text>{{ personId ? "已自动关联" : "未关联人物" }}</text></view
|
||||
></view
|
||||
>
|
||||
<view class="field"
|
||||
><text>记录类型</text
|
||||
><input
|
||||
v-model="form.recordType"
|
||||
placeholder="请输入记录类型"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>记录标题</text
|
||||
><input
|
||||
v-model="form.recordTitle"
|
||||
placeholder="请输入记录标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--textarea"
|
||||
><text>记录内容</text
|
||||
><textarea
|
||||
v-model="form.recordContent"
|
||||
auto-height
|
||||
placeholder="记录成长片段"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.recordDate"
|
||||
@change="selectRecordDate"
|
||||
><view :class="{ placeholder: !form.recordDate }">{{
|
||||
form.recordDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.recordClock"
|
||||
@change="selectRecordClock"
|
||||
><view :class="{ placeholder: !form.recordClock }">{{
|
||||
form.recordClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><picker
|
||||
mode="date"
|
||||
:value="form.remindDate"
|
||||
@change="selectRemindDate"
|
||||
><view :class="{ placeholder: !form.remindDate }">{{
|
||||
form.remindDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view :class="{ placeholder: !form.remindClock }">{{
|
||||
form.remindClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片选定后会先取得真实上传回执,并在提交记录时关联。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交成长记录'"
|
||||
@click="submit"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>成长日志入口无效</text
|
||||
><text>未获取到可查看的家谱标识,请从家族页面重新进入。</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取成长记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取成长记录</text
|
||||
><text>请检查网络后重新加载。</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有成长记录</text
|
||||
><AppButton block label="新建成长记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>成长记录已提交,并已从服务端重新读取。</text
|
||||
>
|
||||
<view v-for="item in records" :key="item.id" class="record-card">
|
||||
<view
|
||||
><text>{{ item.title }}</text
|
||||
><text
|
||||
>{{ item.type || item.personName
|
||||
}}{{ item.date ? ` · ${item.date}` : "" }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃成长记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const personName = ref("当前家谱");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const records = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
lineagePersonId: "",
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const recordTime = computed(() =>
|
||||
form.recordDate ? `${form.recordDate} ${form.recordClock || "00:00"}:00` : "",
|
||||
);
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const dirty = computed(
|
||||
() =>
|
||||
Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
lineagePersonId: personId.value,
|
||||
recordType: "",
|
||||
recordTitle: "",
|
||||
recordContent: "",
|
||||
recordDate: "",
|
||||
recordClock: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getGrowthRecords(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
records.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.recordId || ""),
|
||||
title: String(item.recordTitle || "未命名记录"),
|
||||
type: String(item.recordType || ""),
|
||||
personName: String(item.lineagePersonName || "未关联人物"),
|
||||
date: String(item.recordDate || item.remindTime || ""),
|
||||
content: String(item.recordContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRecordDate = (event) => {
|
||||
form.recordDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRecordClock = (event) => {
|
||||
form.recordClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
mediaReceipts.value = [
|
||||
...mediaReceipts.value,
|
||||
await pickAndUploadImage({ requestController: controller }),
|
||||
];
|
||||
} catch (cause) {
|
||||
if (!isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = cause?.message || "图片上传失败,请稍后重试。";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.recordTitle.trim()) {
|
||||
error.value = "请填写记录标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const { recordClock, remindDate, remindClock, ...recordForm } = form;
|
||||
await appApi.createGrowthRecord(
|
||||
genealogyId.value,
|
||||
{
|
||||
...recordForm,
|
||||
recordDate: recordTime.value,
|
||||
remindTime: remindTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadRecords();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = cause?.message || "成长记录提交失败,请稍后重试。";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
personId.value = /^[1-9]\d*$/.test(String(query?.personId || ""))
|
||||
? String(query.personId)
|
||||
: "";
|
||||
personName.value = String(
|
||||
query?.personName || (personId.value ? "当前查看的人物" : "当前家谱"),
|
||||
);
|
||||
resetForm();
|
||||
if (valid.value) loadRecords();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadRecords();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.record-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.field--context > view {
|
||||
@include adaptive-records-field;
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field--context > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field--context > view > text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
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-field > text,
|
||||
.error {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.record-card {
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.record-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<!-- 页面编号:R-09;用途:人生大事。当前没有独立业务 operation。 -->
|
||||
<template>
|
||||
<view class="life-page"
|
||||
><ModulePageBackground module="records" /><view class="page-header"
|
||||
><PageHeader title="人生大事" custom-back @back="returnToRecords" /></view
|
||||
><view class="page-content"
|
||||
><view class="state-card"
|
||||
><text>人生大事暂未开放</text
|
||||
><text
|
||||
>该功能正在准备中。开放后,重要人生经历会在这里沉淀为家族记忆。</text
|
||||
><AppButton
|
||||
block
|
||||
:label="hasValidContext ? '返回记录首页' : '返回上一页'"
|
||||
@click="returnToRecords" /></view></view
|
||||
></view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
});
|
||||
const returnToRecords = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("R01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.life-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.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>
|
||||
@@ -1,497 +0,0 @@
|
||||
<!-- 页面编号:R-10;用途:读取、新建当前家谱的家族备忘。 -->
|
||||
<template>
|
||||
<view class="memo-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="家族备忘"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建家族备忘</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view class="field"
|
||||
><text><text class="required-mark">*</text>备忘标题</text
|
||||
><input
|
||||
v-model="form.memoTitle"
|
||||
maxlength="40"
|
||||
placeholder="请输入备忘标题"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"
|
||||
><view class="field field--picker"
|
||||
><text>提醒日期</text
|
||||
><text>{{ form.remindDate || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="form.remindClock"
|
||||
@change="selectRemindClock"
|
||||
><view class="field field--picker"
|
||||
><text>提醒时间</text
|
||||
><text>{{ form.remindClock || "请选择" }}</text></view
|
||||
></picker
|
||||
>
|
||||
<view class="field field--textarea"
|
||||
><text>备忘内容</text
|
||||
><textarea
|
||||
v-model="form.memoContent"
|
||||
auto-height
|
||||
maxlength="1200"
|
||||
placeholder="记录需要提醒的事情"
|
||||
@input="error = ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="upload-field">
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片选定后会先取得真实上传回执,并在提交备忘时关联。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
class="upload-button"
|
||||
:disabled="uploading || submitting"
|
||||
@click="uploadImage"
|
||||
>
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<text
|
||||
v-for="(receipt, index) in mediaReceipts"
|
||||
:key="`${receipt.ossId}-${index}`"
|
||||
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||
>
|
||||
<text v-if="uploadError" class="error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : '提交备忘'"
|
||||
@click="submit"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>家族备忘入口无效</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取家族备忘"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取家族备忘</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMemos"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有家族备忘</text
|
||||
><AppButton block label="新建备忘" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="memo-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>备忘已提交,并已从服务端重新读取。</text
|
||||
>
|
||||
<view v-for="item in memos" :key="item.id" class="memo-card"
|
||||
><view
|
||||
><text>{{ item.title }}</text
|
||||
><text v-if="item.remindTime">{{ item.remindTime }}</text
|
||||
><text v-if="item.content" class="memo-card__content">{{ item.content }}</text></view
|
||||
></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃家族备忘?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const memos = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const remindTime = computed(() =>
|
||||
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
|
||||
);
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const dirty = computed(
|
||||
() =>
|
||||
Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
memoTitle: "",
|
||||
remindDate: "",
|
||||
remindClock: "",
|
||||
memoContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadMemos = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getMemos(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
memos.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.memoId || ""),
|
||||
title: String(item.memoTitle || "未命名备忘"),
|
||||
remindTime: String(item.remindTime || ""),
|
||||
content: String(item.memoContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = memos.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectRemindDate = (event) => {
|
||||
form.remindDate = event.detail.value || "";
|
||||
};
|
||||
const selectRemindClock = (event) => {
|
||||
form.remindClock = event.detail.value || "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
mediaReceipts.value = [
|
||||
...mediaReceipts.value,
|
||||
await pickAndUploadImage({ requestController: controller }),
|
||||
];
|
||||
} catch (cause) {
|
||||
if (!isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = cause?.message || "图片上传失败,请稍后重试。";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
if (!form.memoTitle.trim()) {
|
||||
error.value = "请填写备忘标题";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.createMemo(
|
||||
genealogyId.value,
|
||||
{
|
||||
memoTitle: form.memoTitle,
|
||||
remindTime: remindTime.value,
|
||||
memoContent: form.memoContent,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
sortOrder: form.sortOrder,
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMemos();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = cause?.message || "备忘提交失败,请稍后重试。";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value || uploading.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMemos();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMemos();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.memo-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.memo-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 142rpx minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12rpx 20rpx;
|
||||
min-height: 82rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.field > text,
|
||||
.upload-field > view > text:first-child {
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field > text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.field--textarea {
|
||||
align-items: start;
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 116rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
@include adaptive-records-field;
|
||||
}
|
||||
.upload-field > view > text {
|
||||
display: block;
|
||||
}
|
||||
.upload-field > view > text:last-child {
|
||||
margin-top: 6rpx;
|
||||
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-field > text {
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.memo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.memo-card {
|
||||
min-height: 128rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.memo-card text {
|
||||
display: block;
|
||||
}
|
||||
.memo-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.memo-card text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.memo-card__content {
|
||||
display: -webkit-box !important;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,489 +0,0 @@
|
||||
<!-- 页面编号:R-11;用途:读取、新建当前家谱的功德记录。 -->
|
||||
<template>
|
||||
<view class="merit-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="功德记录"
|
||||
:action="valid && view === 'list' ? '新建' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="openCreate"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="view === 'form'" class="form-card">
|
||||
<text>新建功德记录</text>
|
||||
<text class="form-copy">红色 * 为必填项,其余内容可按需补充。</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field"
|
||||
><text
|
||||
><text v-if="field.required" class="required-mark">*</text
|
||||
>{{ field.label }}</text
|
||||
><textarea
|
||||
v-if="field.key === 'content'"
|
||||
v-model="form[field.key]"
|
||||
auto-height
|
||||
:placeholder="`请输入${field.label}`"
|
||||
@input="error = ''" /><input
|
||||
v-else
|
||||
v-model="form[field.key]"
|
||||
:type="field.inputType || 'text'"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
@input="error = ''"
|
||||
/></view>
|
||||
<view class="field field--picker"
|
||||
><text>功德类型</text
|
||||
><picker
|
||||
:range="meritTypeOptions.map((item) => item.label)"
|
||||
:value="meritTypeIndex"
|
||||
@change="selectMeritType"
|
||||
><view :class="{ placeholder: !form.type }">{{
|
||||
meritTypeLabel || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录日期</text
|
||||
><picker mode="date" :value="form.meritDate" @change="selectMeritDate"
|
||||
><view :class="{ placeholder: !form.meritDate }">{{
|
||||
form.meritDate || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="field field--picker"
|
||||
><text>记录时间</text
|
||||
><picker
|
||||
mode="time"
|
||||
:value="form.meritClock"
|
||||
@change="selectMeritClock"
|
||||
><view :class="{ placeholder: !form.meritClock }">{{
|
||||
form.meritClock || "请选择"
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
<view class="form-actions"
|
||||
><AppButton
|
||||
type="secondary"
|
||||
label="取消"
|
||||
@click="cancelCreate" /><AppButton
|
||||
:disabled="submitting"
|
||||
:label="submitting ? '正在提交' : '提交功德记录'"
|
||||
@click="submit"
|
||||
/></view>
|
||||
</view>
|
||||
<view v-else-if="!valid" class="state-card"
|
||||
><text>功德记录入口无效</text
|
||||
><AppButton block label="返回上一页" @click="requestBack"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取功德记录"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'error'" class="state-card"
|
||||
><text>暂时无法读取功德记录</text
|
||||
><AppButton block type="secondary" label="重新加载" @click="loadMerits"
|
||||
/></view>
|
||||
<view v-else-if="listState === 'empty'" class="state-card"
|
||||
><text>还没有功德记录</text
|
||||
><AppButton block label="新建功德记录" @click="openCreate"
|
||||
/></view>
|
||||
<view v-else class="merit-list">
|
||||
<text v-if="saveNotice" class="save-notice"
|
||||
>功德记录已提交,并已从服务端重新读取。</text
|
||||
>
|
||||
<text class="merit-summary"
|
||||
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}</text
|
||||
>
|
||||
<view v-for="item in merits" :key="item.id" class="merit-card"
|
||||
><view
|
||||
><text>{{ item.title }}</text
|
||||
><text
|
||||
>{{ item.donor }}{{ item.type ? ` · ${item.type}` : "" }}</text
|
||||
><text v-if="item.time || item.content">{{
|
||||
item.time || item.content
|
||||
}}</text></view
|
||||
><text>¥{{ item.amount }}</text></view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃功德记录?"
|
||||
message="尚未提交的内容将被清除。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const merits = ref([]);
|
||||
const saveNotice = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const fields = [
|
||||
{ key: "donor", label: "捐赠人", required: true },
|
||||
{ key: "title", label: "功德标题", required: true },
|
||||
{ key: "amount", inputType: "digit", label: "金额" },
|
||||
{ key: "content", label: "记录内容" },
|
||||
{
|
||||
key: "sortOrder",
|
||||
label: "排序值",
|
||||
inputType: "number",
|
||||
placeholder: "数值越小越靠前",
|
||||
},
|
||||
];
|
||||
const meritTypeOptions = Object.freeze([
|
||||
{ value: "donation", label: "捐赠" },
|
||||
{ value: "repair", label: "修祠" },
|
||||
{ value: "public", label: "公益" },
|
||||
{ value: "other", label: "其他" },
|
||||
]);
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() =>
|
||||
Object.values(form).some((value) => String(value).trim()),
|
||||
);
|
||||
const meritTime = computed(() =>
|
||||
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
|
||||
);
|
||||
const meritTypeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
meritTypeOptions.findIndex((item) => item.value === form.type),
|
||||
),
|
||||
);
|
||||
const meritTypeLabel = computed(
|
||||
() => meritTypeOptions.find((item) => item.value === form.type)?.label || "",
|
||||
);
|
||||
const totalAmount = computed(() =>
|
||||
merits.value.reduce((total, item) => total + item.amount, 0).toFixed(2),
|
||||
);
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const confirmDiscard = () => {
|
||||
confirmation.confirm();
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const cancelDiscard = confirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
donor: "",
|
||||
title: "",
|
||||
type: "",
|
||||
amount: "",
|
||||
meritDate: "",
|
||||
meritClock: "",
|
||||
content: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
error.value = "";
|
||||
};
|
||||
const loadMerits = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
listState.value = "loading";
|
||||
try {
|
||||
const rows = await appApi.getMeritRecords(genealogyId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
merits.value = rows
|
||||
.map((item) => ({
|
||||
id: String(item.meritId || ""),
|
||||
donor: String(item.donorName || "未署名"),
|
||||
title: String(item.meritTitle || "未命名功德"),
|
||||
type: String(item.meritType || ""),
|
||||
amount: Number.isFinite(Number(item.amount)) ? Number(item.amount) : 0,
|
||||
time: String(item.meritTime || ""),
|
||||
content: String(item.meritContent || ""),
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
listState.value = merits.value.length ? "ready" : "empty";
|
||||
} catch (cause) {
|
||||
if (!active || isRequestCancelled(cause)) return;
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
if (!valid.value) return;
|
||||
saveNotice.value = false;
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
};
|
||||
const selectMeritDate = (event) => {
|
||||
form.meritDate = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritClock = (event) => {
|
||||
form.meritClock = event.detail.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritType = (event) => {
|
||||
form.type = meritTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const submit = async () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
const meritTitle = form.title.trim();
|
||||
const amountText = form.amount.trim();
|
||||
if (!donorName || !meritTitle) {
|
||||
error.value = !donorName ? "请填写捐赠人" : "请填写功德标题";
|
||||
return;
|
||||
}
|
||||
if (amountText && !Number.isFinite(Number(amountText))) {
|
||||
error.value = "金额必须是数字";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.createMeritRecord(
|
||||
genealogyId.value,
|
||||
{
|
||||
donorName,
|
||||
meritTitle,
|
||||
meritType: form.type,
|
||||
meritContent: form.content,
|
||||
meritTime: meritTime.value,
|
||||
sortOrder: form.sortOrder,
|
||||
...(amountText ? { amount: Number(amountText) } : {}),
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
saveNotice.value = true;
|
||||
await loadMerits();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = cause?.message || "功德记录提交失败,请稍后重试。";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
view.value !== "form"
|
||||
? goBack()
|
||||
: runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value,
|
||||
submitting: submitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMerits();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && view.value === "list" && listState.value !== "loading")
|
||||
loadMerits();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.merit-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.form-card,
|
||||
.state-card,
|
||||
.merit-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
padding: 46rpx;
|
||||
}
|
||||
.form-card > text:first-child {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.field > text {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.required-mark {
|
||||
display: inline-block;
|
||||
margin-right: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
transform: translateY(-3rpx);
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field--picker picker > view {
|
||||
@include adaptive-records-field;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 150rpx;
|
||||
}
|
||||
.field--picker picker {
|
||||
display: block;
|
||||
}
|
||||
.field--picker .placeholder {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.form-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.form-actions .app-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.merit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-summary {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.merit-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.merit-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.merit-card text {
|
||||
display: block;
|
||||
}
|
||||
.merit-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.merit-card > text {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:R-04;用途:按 Apifox 的完整 AppRelativeRecordBody 创建亲友往来记录。 -->
|
||||
<template>
|
||||
<view
|
||||
class="gift-editor-page"
|
||||
@@ -6,13 +5,13 @@
|
||||
>
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader title="新建往来记录" custom-back @back="requestBack"
|
||||
><PageHeader :title="isEdit ? '编辑往来记录' : '新建往来记录'" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="editorState === 'form'" class="form-card">
|
||||
<text>记录一份家人往来</text>
|
||||
<text>{{ isEdit ? '编辑一份家人往来' : '记录一份家人往来' }}</text>
|
||||
<text class="form-copy"
|
||||
>除亲友姓名外,其他字段均可按需填写;留空时不提交该字段。</text
|
||||
>{{ isEdit ? '原有图片和相关设置会保留。' : '除亲友姓名外,其他内容可按需填写。' }}</text
|
||||
>
|
||||
<view class="field-row"
|
||||
><text><text class="required-mark">*</text>亲友姓名</text
|
||||
@@ -71,7 +70,7 @@
|
||||
<view
|
||||
><text>相关图片</text
|
||||
><text
|
||||
>图片选定后会先取得真实上传回执,并在提交记录时关联。</text
|
||||
>图片上传成功后,会随这条记录一起保存。</text
|
||||
></view
|
||||
>
|
||||
<button
|
||||
@@ -88,22 +87,17 @@
|
||||
>
|
||||
<text v-if="uploadError" class="save-error">{{ uploadError }}</text>
|
||||
</view>
|
||||
<view class="field-row"
|
||||
><text>排序值</text
|
||||
><input
|
||||
v-model="form.sortOrder"
|
||||
type="number"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="submitError = ''"
|
||||
/></view>
|
||||
<text v-if="submitError" class="save-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmitting || isUploading"
|
||||
:label="isSubmitting ? '正在提交' : '提交往来记录'"
|
||||
:label="isSubmitting ? '正在提交' : isEdit ? '保存往来记录' : '提交往来记录'"
|
||||
@click="saveRelative"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="editorState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取往来记录"
|
||||
/></view>
|
||||
<view v-else class="state-card"
|
||||
><text>{{ resultCopy.title }}</text
|
||||
><text>{{ resultCopy.copy }}</text
|
||||
@@ -129,36 +123,42 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const editorState = ref("form");
|
||||
const editorState = ref("loading");
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const editingRecord = ref(null);
|
||||
const formBaseline = ref("");
|
||||
const submittedEdit = ref(false);
|
||||
const form = reactive({
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
@@ -167,32 +167,47 @@ const form = reactive({
|
||||
eventClock: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const mediaReceipts = ref([]);
|
||||
const requestController = createRequestController();
|
||||
const relativeRecordDetailController = createRequestController();
|
||||
const relativeRecordMediaUploadController = createRequestController();
|
||||
const relativeRecordSaveController = createRequestController();
|
||||
const relativeRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const isEdit = computed(() => Boolean(editingRecord.value));
|
||||
const eventTime = computed(() =>
|
||||
form.eventDate ? `${form.eventDate} ${form.eventClock || "00:00"}:00` : "",
|
||||
);
|
||||
const formSnapshot = computed(() =>
|
||||
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||
);
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()) ||
|
||||
mediaReceipts.value.length > 0,
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() =>
|
||||
editorState.value === "success"
|
||||
? {
|
||||
title: "往来记录已提交服务端",
|
||||
copy: "返回贺礼簿后会重新读取服务端记录。",
|
||||
title: submittedEdit.value ? "往来记录已更新" : "往来记录已保存",
|
||||
copy: "返回贺礼簿后会显示最新内容。",
|
||||
action: "返回贺礼簿",
|
||||
}
|
||||
: editorState.value === "error"
|
||||
? {
|
||||
title: "这条往来记录暂时无法编辑",
|
||||
copy: "这条记录已经变化,暂未保存。",
|
||||
action: "返回贺礼簿",
|
||||
}
|
||||
: {
|
||||
title: "往来记录入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属记录。",
|
||||
title: "暂时无法打开往来记录",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
@@ -203,10 +218,87 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
eventName: "",
|
||||
eventDate: "",
|
||||
eventClock: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingRecord.value = null;
|
||||
formBaseline.value = "";
|
||||
submitError.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const splitEventTime = (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 loadEditRecord = async (relativeId) => {
|
||||
editorState.value = "loading";
|
||||
try {
|
||||
const detail = await lifeRecordApi.getRelativeRecordDetail(
|
||||
genealogyId.value,
|
||||
relativeId,
|
||||
{ requestController: relativeRecordDetailController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (!detail.canEdit ||
|
||||
detail.name === "未命名亲友" ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)) {
|
||||
throw new Error("这条记录的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||
}
|
||||
const event = splitEventTime(detail.eventTime);
|
||||
resetForm();
|
||||
Object.assign(form, {
|
||||
relativeName: detail.name,
|
||||
relationName: detail.relation,
|
||||
eventName: detail.event,
|
||||
eventDate: event.date,
|
||||
eventClock: event.clock,
|
||||
giftAmount: detail.amount,
|
||||
recordContent: detail.content,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: file.ossId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingRecord.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
status: detail.status,
|
||||
};
|
||||
formBaseline.value = formSnapshot.value;
|
||||
editorState.value = "form";
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) editorState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create")
|
||||
if (!hasValidContext.value) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
if (query?.mode === "create") {
|
||||
editorState.value = "form";
|
||||
return;
|
||||
}
|
||||
const relativeId = String(query?.relativeId || "");
|
||||
if (query?.mode === "edit" && /^[1-9]\d*$/.test(relativeId)) {
|
||||
loadEditRecord(relativeId);
|
||||
return;
|
||||
}
|
||||
editorState.value = "invalid";
|
||||
});
|
||||
const selectEventDate = (event) => {
|
||||
form.eventDate = event.detail.value || "";
|
||||
@@ -221,15 +313,16 @@ const uploadImage = async () => {
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
mediaReceipts.value = [
|
||||
...mediaReceipts.value,
|
||||
await pickAndUploadImage({ requestController }),
|
||||
];
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: relativeRecordMediaUploadController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (error) {
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
|
||||
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
if (pageActive) isUploading.value = false;
|
||||
}
|
||||
};
|
||||
const saveRelative = async () => {
|
||||
@@ -238,36 +331,60 @@ const saveRelative = async () => {
|
||||
submitError.value = "请填写亲友姓名";
|
||||
return;
|
||||
}
|
||||
const { eventDate, eventClock, ...recordForm } = form;
|
||||
const payload = {
|
||||
...recordForm,
|
||||
eventTime: eventTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(editingRecord.value
|
||||
? {
|
||||
sortOrder: editingRecord.value.sortOrder,
|
||||
status: editingRecord.value.status,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const createAttempt = editingRecord.value
|
||||
? null
|
||||
: relativeRecordCreateGuard.begin(payload);
|
||||
if (!editingRecord.value && createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次提交结果暂时无法确认,请先返回贺礼簿检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const { eventDate, eventClock, ...recordForm } = form;
|
||||
await appApi.createRelativeRecord(
|
||||
genealogyId.value,
|
||||
{
|
||||
...recordForm,
|
||||
eventTime: eventTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
Object.assign(form, {
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
eventName: "",
|
||||
eventDate: "",
|
||||
eventClock: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
submittedEdit.value = Boolean(editingRecord.value);
|
||||
if (editingRecord.value) {
|
||||
await lifeRecordApi.updateRelativeRecord(
|
||||
genealogyId.value,
|
||||
editingRecord.value.id,
|
||||
payload,
|
||||
{ requestController: relativeRecordSaveController },
|
||||
);
|
||||
} else {
|
||||
await lifeRecordApi.createRelativeRecord(genealogyId.value, payload, {
|
||||
requestController: relativeRecordSaveController,
|
||||
});
|
||||
}
|
||||
if (!pageActive) return;
|
||||
resetForm();
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!editingRecord.value &&
|
||||
relativeRecordCreateGuard.recordFailure(createAttempt, error)
|
||||
) {
|
||||
submitError.value =
|
||||
"提交结果暂时无法确认,请先返回贺礼簿检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "往来记录提交失败,请稍后重试。";
|
||||
submitError.value = getRequestErrorMessage(error, "往来记录提交失败,请稍后重试。");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
@@ -280,12 +397,15 @@ const requestBack = () =>
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
const handleResultAction = () =>
|
||||
editorState.value === "success"
|
||||
["success", "error"].includes(editorState.value)
|
||||
? returnTo("R03", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
relativeRecordDetailController.abort();
|
||||
relativeRecordMediaUploadController.abort();
|
||||
relativeRecordSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<view class="gift-page">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header"
|
||||
><PageHeader
|
||||
title="贺礼簿"
|
||||
:action="valid ? '新建' : ''"
|
||||
custom-back
|
||||
@back="returnToFamily"
|
||||
@action="createRelative"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>暂时无法打开贺礼簿</text
|
||||
><AppButton block label="返回上一页" @click="returnToFamily"
|
||||
/></view>
|
||||
<view v-else-if="relativeRecordListState === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取亲友往来"
|
||||
/></view>
|
||||
<view v-else-if="relativeRecordListState === 'error'" class="state-card"
|
||||
><text>暂时无法读取亲友往来</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadRecords"
|
||||
/></view>
|
||||
<view v-else-if="relativeRecordListState === 'empty'" class="state-card"
|
||||
><text>还没有亲友往来记录</text
|
||||
><AppButton block label="新建往来记录" @click="createRelative"
|
||||
/></view>
|
||||
<view v-else class="record-list">
|
||||
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="`查看${item.name}的往来详情`" @click="openRecordDetail(item)">
|
||||
<view
|
||||
><text>{{ item.name }}</text
|
||||
><text
|
||||
>{{ item.relation
|
||||
}}{{ item.event ? ` · ${item.event}` : "" }}</text
|
||||
><text v-if="item.time">{{ item.time }}</text
|
||||
><text v-if="item.content">{{ item.content }}</text></view
|
||||
>
|
||||
<view class="record-card__amount">
|
||||
<text v-if="item.amount">¥{{ item.amount }}</text>
|
||||
<AppButton
|
||||
v-if="item.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑"
|
||||
@click.stop="openEditRelative(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="item.canDelete"
|
||||
compact
|
||||
type="secondary"
|
||||
label="删除"
|
||||
@click.stop="requestDeleteRecord(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(detailTarget)"
|
||||
eyebrow="往来详情"
|
||||
:title="detailTarget?.name || '亲友往来'"
|
||||
confirm-text="关闭"
|
||||
:close-on-mask="detailState !== 'loading'"
|
||||
@confirm="closeRecordDetail"
|
||||
@cancel="closeRecordDetail"
|
||||
>
|
||||
<view class="detail-content">
|
||||
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
|
||||
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
|
||||
<template v-else>
|
||||
<text>关系:{{ detailTarget?.relation || "未填写" }}</text>
|
||||
<text>事项:{{ detailTarget?.event || "未填写" }}</text>
|
||||
<text>时间:{{ detailTarget?.time || "未填写" }}</text>
|
||||
<text>金额:{{ detailTarget?.amount ? `¥${detailTarget.amount}` : "未填写" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</text>
|
||||
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
|
||||
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" aria-label="查看往来记录图片" @click="previewDetailMedia(file)" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这条亲友往来?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="deleteRecord"
|
||||
@cancel="closeDeleteConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, 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 { lifeRecordApi } from "@/services/api/life-record-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const records = ref([]);
|
||||
const relativeRecordListState = ref("loading");
|
||||
const relativeRecordListController = createRequestController();
|
||||
const relativeRecordDeleteController = createRequestController();
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref("");
|
||||
const detailTarget = ref(null);
|
||||
const detailState = ref("idle");
|
||||
const detailError = ref("");
|
||||
const relativeRecordDetailController = createRequestController();
|
||||
let isPageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadRecords = async () => {
|
||||
if (!valid.value) return;
|
||||
relativeRecordListController.abort();
|
||||
relativeRecordListState.value = "loading";
|
||||
try {
|
||||
const rows = await lifeRecordApi.getRelativeRecords(genealogyId.value, {
|
||||
requestController: relativeRecordListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
records.value = rows;
|
||||
relativeRecordListState.value = records.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
relativeRecordListState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToFamily = () =>
|
||||
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
|
||||
const createRelative = () =>
|
||||
valid.value
|
||||
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
|
||||
: Promise.resolve(false);
|
||||
const openEditRelative = (record) =>
|
||||
record?.canEdit && valid.value
|
||||
? openPage(
|
||||
"R04",
|
||||
{ genealogyId: genealogyId.value, mode: "edit", relativeId: record.id },
|
||||
"R03",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const openRecordDetail = async (record) => {
|
||||
if (!record?.id || detailState.value === "loading") return;
|
||||
detailTarget.value = record;
|
||||
detailState.value = "loading";
|
||||
detailError.value = "";
|
||||
relativeRecordDetailController.abort();
|
||||
try {
|
||||
const loadedRecord = await lifeRecordApi.getRelativeRecordDetail(genealogyId.value, record.id, {
|
||||
requestController: relativeRecordDetailController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
detailTarget.value = loadedRecord;
|
||||
detailState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
detailState.value = "error";
|
||||
detailError.value = getRequestErrorMessage(error, "完整记录读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const closeRecordDetail = () => {
|
||||
if (detailState.value === "loading") return;
|
||||
detailTarget.value = null;
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const previewDetailMedia = (file) => {
|
||||
const urls = detailTarget.value?.mediaFiles?.map((item) => item.accessUrl).filter(Boolean) || [];
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
const requestDeleteRecord = (record) => {
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleteError.value = "";
|
||||
deleteTarget.value = record;
|
||||
deleteConfirmationVisible.value = true;
|
||||
};
|
||||
const closeDeleteConfirmation = () => {
|
||||
if (deleting.value) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
};
|
||||
const deleteRecord = async () => {
|
||||
const record = deleteTarget.value;
|
||||
if (!record?.canDelete || deleting.value) return;
|
||||
deleting.value = true;
|
||||
deleteError.value = "";
|
||||
try {
|
||||
await lifeRecordApi.deleteRelativeRecord(genealogyId.value, record.id, {
|
||||
requestController: relativeRecordDeleteController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
deleteConfirmationVisible.value = false;
|
||||
deleteTarget.value = null;
|
||||
await loadRecords();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
deleteError.value = getRequestErrorMessage(error, "亲友往来删除失败,请稍后重试。");
|
||||
deleteConfirmationVisible.value = false;
|
||||
} finally {
|
||||
if (isPageActive) deleting.value = false;
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadRecords();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && relativeRecordListState.value !== "loading") loadRecords();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
relativeRecordListController.abort();
|
||||
relativeRecordDeleteController.abort();
|
||||
relativeRecordDetailController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.gift-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card,
|
||||
.record-card {
|
||||
@include adaptive-records-content;
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 320rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.record-card {
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 18rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.record-card > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.record-card text {
|
||||
display: block;
|
||||
}
|
||||
.record-card > view text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card > view text:not(:first-child) {
|
||||
margin-top: 7rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-card__amount {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.record-card__amount > text {
|
||||
margin-right: auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.record-card__amount .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
}
|
||||
.delete-error {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
|
||||
.detail-error { color: $brand-red !important; }
|
||||
.detail-media { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16rpx; gap: 10rpx; }
|
||||
.detail-media image { width: 100%; height: 150rpx; border-radius: 8rpx; background: rgba(128, 89, 49, .12); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user