Files
jiapuapp/pages/records/relative-records.vue
T
2026-09-13 17:45:52 +08:00

428 lines
15 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-page">
<ModulePageBackground module="records" />
<view class="page-header"
><PageHeader
title="贺礼簿"
:action="valid ? '新建' : ''"
custom-back
@back="requestBack"
@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">
<BatchManagementBar
v-if="deletableRecords.length"
resource-name="往来记录"
:active="recordBatch.selectionMode.value"
:selected-count="recordBatch.selectedCount.value"
:all-selected="recordBatch.allSelected.value"
:busy="recordBatch.deleting.value"
@start="recordBatch.enterSelectionMode"
@finish="recordBatch.exitSelectionMode"
@toggle-all="recordBatch.toggleAll"
@delete="recordBatch.requestDelete"
/>
<text v-if="recordBatch.notice.value" class="batch-notice" role="status">{{ recordBatch.notice.value }}</text>
<text v-if="recordBatch.error.value" class="delete-error" role="alert">{{ recordBatch.error.value }}</text>
<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="recordBatch.selectionMode.value && item.canDelete ? `${recordBatch.isSelected(item) ? '取消选择' : '选择'}${item.name}` : `查看${item.name}的往来详情`" @click="handleRecordClick(item)">
<BatchSelectionMark
v-if="recordBatch.selectionMode.value && item.canDelete"
:selected="recordBatch.isSelected(item)"
:label="`往来记录:${item.name}`"
@toggle="recordBatch.toggleSelection(item)"
/>
<image
v-if="item.mediaFiles?.[0]?.accessUrl"
class="record-card__cover"
:src="item.mediaFiles[0].accessUrl"
mode="aspectFill"
aria-hidden="true"
/>
<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.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
><text v-if="item.content">{{ item.content }}</text></view
>
<view v-if="!recordBatch.selectionMode.value" 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="recordBatch.confirmationVisible.value"
title="将选中的往来记录移入回收站?"
:message="recordBatch.confirmationMessage.value"
:confirm-text="recordBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="recordBatch.confirmDelete"
@cancel="recordBatch.cancelDelete"
/>
<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 v-if="detailTarget?.createTime">创建时间{{ formatMinuteTimestamp(detailTarget.createTime) }}</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 { 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 BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useBatchDeletion } from "@/composables/use-batch-deletion.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 { formatMinuteTimestamp } from "@/utils/display-time.js";
import { goBack, handleBackPress, 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 recordBatch = useBatchDeletion({
items: records,
deleteOne: (record) =>
lifeRecordApi.deleteRelativeRecord(genealogyId.value, record.id, {
requestController: relativeRecordDeleteController,
}),
resourceName: "往来记录",
isActive: () => isPageActive,
onEmpty: () => {
relativeRecordListState.value = "empty";
},
});
const deletableRecords = recordBatch.deletableItems;
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;
recordBatch.exitSelectionMode();
relativeRecordListState.value = records.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
relativeRecordListState.value = "error";
}
};
const returnToFamily = () =>
valid.value ? returnTo("G05", { 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 handleRecordClick = (record) => {
if (recordBatch.selectionMode.value && record?.canDelete) {
recordBatch.toggleSelection(record);
return;
}
openRecordDetail(record);
};
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 requestBack = () => {
if (deleting.value || recordBatch.deleting.value || detailState.value === "loading") return true;
if (recordBatch.confirmationVisible.value) {
recordBatch.cancelDelete();
return true;
}
if (recordBatch.selectionMode.value) {
recordBatch.exitSelectionMode();
return true;
}
if (deleteConfirmationVisible.value) {
closeDeleteConfirmation();
return true;
}
if (detailTarget.value) {
closeRecordDetail();
return true;
}
return returnToFamily();
};
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();
});
onBackPress((event) => handleBackPress(event, requestBack));
</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 {
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.record-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.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: 20rpx;
}
.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__cover {
width: 100%;
height: 240rpx;
border-radius: 10rpx;
background: rgba(128, 89, 49, 0.12);
}
.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: 80rpx;
}
.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>