feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+52 -11
View File
@@ -26,6 +26,7 @@
:label="protectionSubmitting ? '正在验证' : '解锁并查看'"
@click="unlockContent"
/>
<button class="detail-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码</button>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</template>
<template v-else>
@@ -33,18 +34,26 @@
<text>关联人物{{ detailRecord?.personName || "未关联人物" }}</text>
<text>记录类型{{ detailRecord?.typeLabel || "未填写" }}</text>
<text>记录日期{{ detailRecord?.recordDate || "未填写" }}</text>
<text v-if="detailRecord?.createTime">创建时间{{ formatMinuteTimestamp(detailRecord.createTime) }}</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)"
/>
<template v-for="file in detailRecord.mediaFiles" :key="file.fileId">
<video
v-if="isVideoFile(file)"
:src="file.accessUrl"
controls
:aria-label="file.fileName || '播放成长记录视频'"
/>
<image
v-else
:src="file.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看成长记录图片"
@click="previewMedia(file)"
/>
</template>
</view>
<view v-if="detailRecord?.canManageProtection" class="detail-protection-actions">
<AppButton
@@ -87,6 +96,15 @@
/>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</AppDialog>
<ContentPasswordRecoveryDialog
:visible="passwordRecoveryVisible"
:genealogy-id="genealogyId"
resource-type="GROWTH_RECORD"
:resource-id="String(detailRecord?.id || '')"
@close="passwordRecoveryVisible = false"
@complete="completePasswordRecovery"
@busy-change="passwordRecoveryBusy = $event"
/>
</template>
<script setup>
@@ -94,6 +112,8 @@ 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 { formatMinuteTimestamp } from "@/utils/display-time.js";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
createRequestController,
isRequestCancelled
@@ -117,6 +137,8 @@ const contentPassword = ref("");
const protectionSubmitting = ref(false);
const protectionVisible = ref(false);
const protectionMode = ref("set");
const passwordRecoveryVisible = ref(false);
const passwordRecoveryBusy = ref(false);
const growthDetailReadRequestController = createRequestController();
const growthDetailUnlockRequestController = createRequestController();
const growthProtectionMutationRequestController = createRequestController();
@@ -136,7 +158,7 @@ const protectionConfirmText = computed(() => {
return protectionMode.value === "disable" ? "确认关闭" : "确认保存";
});
const isBusy = computed(() =>
detailState.value === "loading" || protectionSubmitting.value,
detailState.value === "loading" || protectionSubmitting.value || passwordRecoveryBusy.value,
);
const hasTransient = computed(() =>
Boolean(detailRecord.value) || protectionVisible.value,
@@ -155,6 +177,7 @@ const clearDetailState = () => {
detailError.value = "";
contentPassword.value = "";
protectionVisible.value = false;
passwordRecoveryVisible.value = false;
};
const applyRecordDetail = (recordDetail) => {
detailRecord.value = {
@@ -231,6 +254,10 @@ const unlockContent = async () => {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const completePasswordRecovery = (newPassword) => {
contentPassword.value = newPassword;
detailError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openProtection = (mode) => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
@@ -315,6 +342,11 @@ const close = () => {
return true;
};
const closeTransient = () => {
if (passwordRecoveryVisible.value) {
if (passwordRecoveryBusy.value) return false;
passwordRecoveryVisible.value = false;
return true;
}
if (protectionVisible.value) {
closeProtection();
return !protectionSubmitting.value;
@@ -323,11 +355,17 @@ const closeTransient = () => {
};
const previewMedia = (file) => {
const urls = detailRecord.value?.mediaFiles
?.filter((mediaFile) => !isVideoFile(mediaFile))
?.map((mediaFile) => mediaFile.accessUrl)
.filter(Boolean) || [];
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
const isVideoFile = (file) => {
const mediaType = String(file?.mediaType || "").toLowerCase();
const fileName = String(file?.fileName || "");
return mediaType === "video" || mediaType.startsWith("video/") || /\.(mp4|mov|m4v|webm|avi|mkv)$/i.test(fileName);
};
defineExpose({ closeTransient, open });
@@ -365,7 +403,8 @@ onUnmounted(() => {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10rpx;
}
.detail-media image {
.detail-media image,
.detail-media video {
width: 100%;
height: 150rpx;
border-radius: 8rpx;
@@ -383,6 +422,8 @@ onUnmounted(() => {
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
.detail-recovery-link { display: block; min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
.detail-recovery-link::after { border: 0; }
.detail-protection-actions {
display: flex;
flex-wrap: wrap;