Files
jiapuapp/pages/records/memos.vue
T

749 lines
24 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="memo-page">
<ModulePageBackground module="records" />
<view class="page-header"
><PageHeader
:title="pageTitle"
: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 ? `编辑${recordName}` : `新建${recordName}` }}</text>
<text class="form-copy">{{ isEdit ? '原有关联图片、完成状态和排序会随本次保存保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
<view class="field"
><text><text class="required-mark">*</text>{{ isBenefactorMode ? "恩人姓名" : "备忘标题" }}</text
><input
v-model="form.memoTitle"
maxlength="40"
:placeholder="isBenefactorMode ? '请输入恩人姓名' : '请输入备忘标题'"
@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>{{ isBenefactorMode ? "恩人事迹" : "备忘内容" }}</text
><textarea
v-model="form.memoContent"
auto-height
maxlength="1200"
:placeholder="isBenefactorMode ? '记录恩人事迹与家族渊源' : '记录需要提醒的事情'"
@input="error = ''"
/>
</view>
<view class="upload-field">
<view
><text>相关图片</text
><text
>图片上传成功后会随{{ recordName }}一起保存</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 ? '正在提交' : `提交${recordName}`"
@click="saveMemo"
/></view>
</view>
<view v-else-if="!valid" class="state-card"
><text>暂时无法打开{{ recordName }}</text
><AppButton block label="返回上一页" @click="requestBack"
/></view>
<view v-else-if="listState === 'loading'" class="state-card"
><AppLoading :text="`正在读取${recordName}`"
/></view>
<view v-else-if="listState === 'error'" class="state-card"
><text>暂时无法读取{{ recordName }}</text
><AppButton block type="secondary" label="重新加载" @click="loadMemos"
/></view>
<view v-else-if="listState === 'empty'" class="state-card"
><text>还没有{{ recordName }}</text
><AppButton block :label="`新建${recordName}`" @click="openCreate"
/></view>
<view v-else class="memo-list">
<text v-if="saveNotice" class="save-notice"
>{{ recordName }}已保存</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.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</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="`${recordName}详情`"
:title="detailTarget?.title || recordName"
confirm-text="关闭"
:close-on-mask="detailState !== 'loading'"
@confirm="closeMemoDetail"
@cancel="closeMemoDetail"
>
<view class="detail-content">
<AppLoading v-if="detailState === 'loading'" :text="`正在读取完整${recordName}`" />
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
<template v-else>
<text>提醒时间{{ detailTarget?.remindTime || "未设置" }}</text>
<text>完成状态{{ detailTarget?.completed === "1" ? "已完成" : "未完成" }}</text>
<text v-if="detailTarget?.createTime">创建时间{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
<text class="detail-content__body">{{ detailTarget?.content || `未填写${recordName}内容` }}</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="`查看${recordName}图片`" @click="previewDetailMedia(file)" />
</view>
</template>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
:title="`放弃${recordName}`"
message="尚未提交的内容将被清除。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="deleteConfirmationVisible"
:title="`将这条${recordName}移至回收站?`"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
:cancel-text="`保留${recordName}`"
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 { MEMO_TYPE } from "@/services/api/life-record-contract.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 { formatMinuteTimestamp } from "@/utils/display-time.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 memoType = ref(MEMO_TYPE.GENERAL);
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 isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
const pageTitle = computed(() => recordName.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.filter((memo) => memo.memoType === memoType.value);
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 = {
memoType: memoType.value,
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 || "");
memoType.value = query?.memoType === MEMO_TYPE.BENEFACTOR
? MEMO_TYPE.BENEFACTOR
: MEMO_TYPE.GENERAL;
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 {
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.form-card,
.state-card,
.memo-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.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: 20rpx;
}
.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: 80rpx;
}
.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>