400 lines
13 KiB
Vue
400 lines
13 KiB
Vue
<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>
|