Files
jiapuapp/components/records/GrowthRecordDetailDialog.vue
T

400 lines
13 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>
<AppDialog
:visible="Boolean(detailRecord)"
eyebrow="成长详情"
:title="detailRecord?.title || '成长记录'"
confirm-text="关闭"
:close-on-mask="detailState !== 'loading'"
@confirm="close"
@cancel="close"
>
<view class="detail-content">
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
<template v-else-if="detailRecord?.contentProtected && !detailRecord?.contentUnlocked">
<text>这条成长记录已设置内容密码</text>
<input
v-model="contentPassword"
class="detail-password-input"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
/>
<AppButton
block
:disabled="protectionSubmitting"
:label="protectionSubmitting ? '正在验证' : '解锁并查看'"
@click="unlockContent"
/>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</template>
<template v-else>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
<text>关联人物{{ detailRecord?.personName || "未关联人物" }}</text>
<text>记录类型{{ detailRecord?.typeLabel || "未填写" }}</text>
<text>记录日期{{ detailRecord?.recordDate || "未填写" }}</text>
<text v-if="detailRecord?.remindTime">提醒时间{{ detailRecord.remindTime }}</text>
<text class="detail-content__body">{{ detailRecord?.content || "未填写记录内容" }}</text>
<view v-if="detailRecord?.mediaFiles?.length" class="detail-media">
<image
v-for="file in detailRecord.mediaFiles"
:key="file.fileId"
:src="file.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看成长记录图片"
@click="previewMedia(file)"
/>
</view>
<view v-if="detailRecord?.canManageProtection" class="detail-protection-actions">
<AppButton
compact
type="secondary"
:label="detailRecord.contentProtected ? '修改内容密码' : '设置内容密码'"
@click="openProtection('set')"
/>
<AppButton
v-if="detailRecord.contentProtected"
compact
type="secondary"
label="关闭内容密码"
@click="openProtection('disable')"
/>
</view>
</template>
</view>
</AppDialog>
<AppDialog
:visible="protectionVisible"
eyebrow="内容密码"
:title="protectionTitle"
:message="protectionMessage"
:confirm-text="protectionConfirmText"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="saveProtection"
@cancel="closeProtection"
>
<input
v-if="protectionMode === 'set'"
v-model="contentPassword"
class="detail-password-input"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
/>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</AppDialog>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.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";
const props = defineProps({
genealogyId: {
type: String,
required: true,
},
});
const emit = defineEmits(["busy-change", "transient-change"]);
const detailRecord = ref(null);
const detailState = ref("idle");
const detailError = ref("");
const contentPassword = ref("");
const protectionSubmitting = ref(false);
const protectionVisible = ref(false);
const protectionMode = ref("set");
const growthDetailReadRequestController = createRequestController();
const growthDetailUnlockRequestController = createRequestController();
const growthProtectionMutationRequestController = createRequestController();
let componentActive = true;
const protectionTitle = computed(() => {
if (protectionMode.value === "disable") return "关闭内容密码?";
return detailRecord.value?.contentProtected ? "修改内容密码" : "设置内容密码";
});
const protectionMessage = computed(() =>
protectionMode.value === "disable"
? "关闭后,有权查看记录的成员无需密码即可阅读。"
: "设置8至128位密码,之后查看完整内容需要先验证。",
);
const protectionConfirmText = computed(() => {
if (protectionSubmitting.value) return "正在保存";
return protectionMode.value === "disable" ? "确认关闭" : "确认保存";
});
const isBusy = computed(() =>
detailState.value === "loading" || protectionSubmitting.value,
);
const hasTransient = computed(() =>
Boolean(detailRecord.value) || protectionVisible.value,
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
watch(hasTransient, (visible) => emit("transient-change", visible), { immediate: true });
const shouldIgnoreFailure = (cause) =>
!componentActive || isRequestCancelled(cause);
const isCurrentRecord = (recordId) =>
componentActive && String(detailRecord.value?.id || "") === String(recordId);
const clearDetailState = () => {
detailRecord.value = null;
detailState.value = "idle";
detailError.value = "";
contentPassword.value = "";
protectionVisible.value = false;
};
const applyRecordDetail = (recordDetail) => {
detailRecord.value = {
...recordDetail,
typeLabel:
recordDetail.typeLabel || detailRecord.value?.typeLabel || "",
};
};
const open = async (record) => {
if (!record?.id || isBusy.value) return;
const recordId = String(record.id);
growthDetailReadRequestController.abort();
detailRecord.value = record;
detailState.value = "loading";
detailError.value = "";
contentPassword.value = "";
try {
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
"",
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
detailState.value = "ready";
} catch (cause) {
if (shouldIgnoreFailure(cause) || !isCurrentRecord(recordId)) return;
detailState.value = "error";
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
}
};
const unlockContent = async () => {
if (!detailRecord.value?.id || protectionSubmitting.value) return;
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
detailError.value = "请输入8至128位内容密码。";
return;
}
protectionSubmitting.value = true;
detailError.value = "";
const recordId = String(detailRecord.value.id);
let passwordAccepted = false;
try {
const grant = await lifeRecordApi.unlockGrowthRecord(
props.genealogyId,
recordId,
contentPassword.value,
{ requestController: growthDetailUnlockRequestController },
);
if (!isCurrentRecord(recordId)) return;
passwordAccepted = true;
contentPassword.value = "";
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
grant.accessToken,
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
detailState.value = "ready";
} catch (cause) {
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
detailError.value = getRequestErrorMessage(
cause,
passwordAccepted
? "密码已验证,但完整内容暂时无法读取,请稍后重试。"
: "密码不正确,请重新输入。",
);
}
} finally {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const openProtection = (mode) => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
protectionMode.value = mode;
contentPassword.value = "";
detailError.value = "";
protectionVisible.value = true;
};
const closeProtection = () => {
if (protectionSubmitting.value) return;
protectionVisible.value = false;
contentPassword.value = "";
detailError.value = "";
};
const saveProtection = async () => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
if (
protectionMode.value === "set" &&
(contentPassword.value.length < 8 || contentPassword.value.length > 128)
) {
detailError.value = "请输入8至128位内容密码。";
return;
}
protectionSubmitting.value = true;
detailError.value = "";
const recordId = String(detailRecord.value.id);
const contentWillBeProtected = protectionMode.value !== "disable";
let protectionCommitted = false;
try {
if (protectionMode.value === "disable") {
await lifeRecordApi.disableGrowthRecordPassword(
props.genealogyId,
recordId,
{ requestController: growthProtectionMutationRequestController },
);
} else {
await lifeRecordApi.setGrowthRecordPassword(
props.genealogyId,
recordId,
contentPassword.value,
{ requestController: growthProtectionMutationRequestController },
);
}
if (!isCurrentRecord(recordId)) return;
protectionCommitted = true;
protectionVisible.value = false;
contentPassword.value = "";
detailRecord.value = {
...detailRecord.value,
contentProtected: contentWillBeProtected,
contentUnlocked: !contentWillBeProtected,
};
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
"",
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
} catch (cause) {
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
detailError.value = getRequestErrorMessage(
cause,
protectionCommitted
? "内容密码已保存,但详情暂时没有刷新,请稍后重新打开。"
: "内容密码设置没有保存,请稍后重试。",
);
if (protectionCommitted) detailState.value = "ready";
}
} finally {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const close = () => {
if (isBusy.value) return false;
growthDetailReadRequestController.abort();
growthDetailUnlockRequestController.abort();
growthProtectionMutationRequestController.abort();
clearDetailState();
return true;
};
const closeTransient = () => {
if (protectionVisible.value) {
closeProtection();
return !protectionSubmitting.value;
}
return detailRecord.value ? close() : false;
};
const previewMedia = (file) => {
const urls = detailRecord.value?.mediaFiles
?.map((mediaFile) => mediaFile.accessUrl)
.filter(Boolean) || [];
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
defineExpose({ closeTransient, open });
onUnmounted(() => {
componentActive = false;
growthDetailReadRequestController.abort();
growthDetailUnlockRequestController.abort();
growthProtectionMutationRequestController.abort();
});
</script>
<style scoped lang="scss">
.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-media {
display: grid;
margin-top: 16rpx;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10rpx;
}
.detail-media image {
width: 100%;
height: 150rpx;
border-radius: 8rpx;
background: rgba(128, 89, 49, .12);
}
.detail-password-input {
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
margin: 16rpx 0;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, .32);
border-radius: 8rpx;
background: #fffdf8;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
.detail-protection-actions {
display: flex;
flex-wrap: wrap;
margin-top: 18rpx;
gap: 10rpx;
}
.detail-error {
display: block;
margin-top: 10rpx;
color: $brand-red !important;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
</style>