feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
@@ -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>
+657
View File
@@ -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;
+932
View File
@@ -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>
+623
View File
@@ -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>
+734
View File
@@ -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>
+716
View File
@@ -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;
-192
View File
@@ -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>
-165
View File
@@ -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>
-602
View File
@@ -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>
-73
View File
@@ -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>
-497
View File
@@ -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>
-489
View File
@@ -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>
+333
View File
@@ -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>