Files
jiapuapp/pages/records/life-events.vue
T

624 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="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>