Files
jiapuapp/pages/records/relative-record-editor.vue
T

562 lines
16 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="gift-editor-page"
:class="`relative-editor-state--${editorState}`"
>
<ModulePageBackground module="records" />
<view class="page-header"
><PageHeader :title="isEdit ? '编辑往来记录' : '新建往来记录'" custom-back @back="requestBack"
/></view>
<view class="page-content">
<view v-if="editorState === 'form'" class="form-card">
<text>{{ isEdit ? '编辑一份家人往来' : '记录一份家人往来' }}</text>
<text class="form-copy"
>{{ isEdit ? '原有图片和相关设置会保留。' : '除亲友姓名外,其他内容可按需填写。' }}</text
>
<view class="field-row"
><text><text class="required-mark">*</text>亲友姓名</text
><input
v-model="form.relativeName"
placeholder="请输入亲友姓名"
@input="submitError = ''"
/></view>
<view class="field-row"
><text>关系称谓</text
><input
v-model="form.relationName"
placeholder="请输入关系称谓"
@input="submitError = ''"
/></view>
<view class="field-row"
><text>礼仪事项</text
><input
v-model="form.eventName"
placeholder="请输入礼仪事项"
@input="submitError = ''"
/></view>
<view class="field-row field-row--picker"
><text>事项日期</text
><picker mode="date" :value="form.eventDate" @change="selectEventDate"
><view>{{ form.eventDate || "请选择" }}</view></picker
></view
>
<view class="field-row field-row--picker"
><text>事项时间</text
><picker
mode="time"
:value="form.eventClock"
@change="selectEventClock"
><view>{{ form.eventClock || "请选择" }}</view></picker
></view
>
<view class="field-row"
><text>礼金金额</text
><input
v-model="form.giftAmount"
type="digit"
placeholder="请输入礼金金额"
@input="submitError = ''"
/></view>
<view class="field-row field-row--textarea"
><text>往来备注</text
><textarea
v-model="form.recordContent"
auto-height
placeholder="记录往来内容"
@input="submitError = ''"
/>
</view>
<view class="upload-field">
<view
><text>相关图片</text
><text
>图片上传成功后会随这条记录一起保存</text
></view
>
<button
class="upload-button"
:disabled="isUploading || isSubmitting"
@click="uploadImage"
>
{{ isUploading ? "上传中…" : "添加图片" }}
</button>
<text
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
>已上传{{ receipt.fileName || "图片" }}</text
>
<text v-if="uploadError" class="save-error">{{ uploadError }}</text>
</view>
<text v-if="submitError" class="save-error">{{ submitError }}</text>
<AppButton
block
:disabled="isSubmitting || isUploading"
: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
><AppButton
block
:label="resultCopy.action"
@click="handleResultAction"
/></view>
</view>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</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 {
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,
returnTo,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
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: "",
eventName: "",
eventDate: "",
eventClock: "",
giftAmount: "",
recordContent: "",
});
const mediaReceipts = ref([]);
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(
() =>
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: submittedEdit.value ? "往来记录已更新" : "往来记录已保存",
copy: "返回贺礼簿后会显示最新内容。",
action: "返回贺礼簿",
}
: editorState.value === "error"
? {
title: "这条往来记录暂时无法编辑",
copy: "这条记录已经变化,暂未保存。",
action: "返回贺礼簿",
}
: {
title: "暂时无法打开往来记录",
copy: "未找到家谱信息,请返回后重新进入。",
action: "返回上一页",
},
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
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) {
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 || "";
submitError.value = "";
};
const selectEventClock = (event) => {
form.eventClock = event.detail.value || "";
submitError.value = "";
};
const uploadImage = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: relativeRecordMediaUploadController,
});
if (!pageActive) return;
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error))
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
} finally {
if (pageActive) isUploading.value = false;
}
};
const saveRelative = async () => {
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.relativeName.trim()) {
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;
}
const giftAmount = form.giftAmount.trim();
if (giftAmount && !/^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(giftAmount)) {
submitError.value = "礼金金额应为 0 至 9999999999.99,最多保留两位小数";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
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 = getRequestErrorMessage(error, "往来记录提交失败,请稍后重试。");
} finally {
if (pageActive) isSubmitting.value = false;
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value || isUploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const handleResultAction = () =>
["success", "error"].includes(editorState.value)
? returnTo("R03", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
relativeRecordDetailController.abort();
relativeRecordMediaUploadController.abort();
relativeRecordSaveController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.gift-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.form-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.form-card > text:first-child,
.state-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-row {
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-row > 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-row input,
.field-row textarea {
min-width: 0;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
text-align: right;
}
.field-row--textarea {
align-items: start;
}
.field-row textarea {
min-height: 72rpx;
text-align: left;
}
.field-row--picker picker {
min-width: 0;
width: 100%;
}
.field-row--picker picker > view {
min-height: 80rpx;
display: flex;
align-items: center;
justify-content: flex-end;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.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: 80rpx;
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;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.form-card .app-button {
margin-top: 18rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
</style>