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
@@ -0,0 +1,281 @@
<template>
<AppDialog
:visible="visible"
eyebrow="内容密码"
title="找回内容密码"
confirm-text="关闭"
:close-on-mask="!isBusy"
@confirm="requestClose"
@cancel="requestClose"
>
<view class="recovery-dialog">
<AppLoading v-if="capabilityState === 'loading'" text="正在核对找回方式" />
<template v-else-if="capabilityState === 'ready' && capability.enabled">
<text class="recovery-dialog__copy"
>验证码将发送至当前账号绑定手机号 {{ capability.maskedPhone }}验证通过后可设置新的内容密码</text
>
<view class="recovery-dialog__code-row">
<input
v-model.trim="smsCode"
type="number"
maxlength="4"
placeholder="4位短信验证码"
/>
<AppButton
compact
type="secondary"
:disabled="isBusy || cooldownSeconds > 0"
:label="codeButtonLabel"
@click="sendRecoveryCode"
/>
</view>
<input
v-model="newPassword"
password
maxlength="128"
placeholder="新的内容密码(8至128位)"
/>
<input
v-model="confirmedPassword"
password
maxlength="128"
placeholder="再次输入新的内容密码"
/>
<AppButton
block
:disabled="isBusy"
:label="resetting ? '正在重置' : '验证并重置密码'"
@click="resetContentPassword"
/>
</template>
<view v-else class="recovery-dialog__state">
<text>{{ capabilityError || capability.disabledReason || "当前内容暂不支持密码找回。" }}</text>
<AppButton
v-if="capabilityState === 'error'"
compact
type="secondary"
label="重试"
@click="loadCapability"
/>
</view>
<text v-if="formError" class="recovery-dialog__error" role="alert">{{ formError }}</text>
<text v-if="resultNotice" class="recovery-dialog__notice" role="status">{{ resultNotice }}</text>
<text class="recovery-dialog__security"
>服务端必须校验当前账号资源查看权限和短信票据并对发送与重置操作限流审计</text
>
</view>
</AppDialog>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import { contentPasswordRecoveryApi } from "@/services/api/content-password-recovery-service.js";
import {
createRequestController,
isRequestCancelled,
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
visible: Boolean,
genealogyId: { type: String, required: true },
resourceType: { type: String, required: true },
resourceId: { type: String, required: true },
});
const emit = defineEmits(["close", "complete", "busy-change"]);
const capabilityState = ref("idle");
const capability = reactive({
enabled: false,
maskedPhone: "",
disabledReason: "",
});
const capabilityError = ref("");
const formError = ref("");
const resultNotice = ref("");
const smsCode = ref("");
const newPassword = ref("");
const confirmedPassword = ref("");
const cooldownSeconds = ref(0);
const sendingCode = ref(false);
const resetting = ref(false);
const capabilityController = createRequestController();
const codeController = createRequestController();
const resetController = createRequestController();
const resetGuard = createNonIdempotentWriteGuard();
let cooldownTimer = null;
let componentActive = true;
const isBusy = computed(() => sendingCode.value || resetting.value);
const codeButtonLabel = computed(() =>
sendingCode.value
? "正在发送"
: cooldownSeconds.value > 0
? `${cooldownSeconds.value}s 后重发`
: "发送验证码",
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
const stopCooldown = () => {
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer = null;
};
const startCooldown = (seconds) => {
stopCooldown();
cooldownSeconds.value = seconds;
if (seconds <= 0) return;
cooldownTimer = setInterval(() => {
cooldownSeconds.value = Math.max(0, cooldownSeconds.value - 1);
if (cooldownSeconds.value === 0) stopCooldown();
}, 1000);
};
const resetForm = () => {
smsCode.value = "";
newPassword.value = "";
confirmedPassword.value = "";
formError.value = "";
resultNotice.value = "";
};
const loadCapability = async () => {
capabilityController.abort();
capabilityState.value = "loading";
capabilityError.value = "";
try {
const result = await contentPasswordRecoveryApi.getCapability(
props.genealogyId,
props.resourceType,
props.resourceId,
{ requestController: capabilityController },
);
if (!componentActive || !props.visible) return;
Object.assign(capability, result);
startCooldown(result.cooldownSeconds);
capabilityState.value = "ready";
} catch (error) {
if (!componentActive || !props.visible || isRequestCancelled(error)) return;
capabilityError.value = getRequestErrorMessage(error, "找回方式暂时无法读取,请稍后重试。");
capabilityState.value = "error";
}
};
const sendRecoveryCode = async () => {
if (!capability.enabled || sendingCode.value || resetting.value || cooldownSeconds.value > 0) return;
sendingCode.value = true;
formError.value = "";
try {
const delivery = await contentPasswordRecoveryApi.sendCode(
props.genealogyId,
props.resourceType,
props.resourceId,
{ requestController: codeController },
);
if (!componentActive || !props.visible) return;
startCooldown(delivery.cooldownSeconds);
resultNotice.value = `验证码已发送至 ${capability.maskedPhone}`;
} catch (error) {
if (componentActive && props.visible && !isRequestCancelled(error)) {
formError.value = getRequestErrorMessage(error, "验证码发送失败,请稍后重试。");
}
} finally {
if (componentActive) sendingCode.value = false;
}
};
const resetContentPassword = async () => {
if (!capability.enabled || isBusy.value) return;
if (!/^\d{4}$/.test(smsCode.value)) {
formError.value = "请输入4位短信验证码。";
return;
}
if (newPassword.value.length < 8 || newPassword.value.length > 128) {
formError.value = "请输入8至128位新的内容密码。";
return;
}
if (newPassword.value !== confirmedPassword.value) {
formError.value = "两次输入的新密码不一致。";
return;
}
const payload = { smsCode: smsCode.value, newPassword: newPassword.value };
const resetAttempt = resetGuard.begin(payload);
if (resetAttempt === null) {
formError.value = "上次重置结果待确认,请先关闭窗口并尝试使用新密码解锁。";
return;
}
resetting.value = true;
formError.value = "";
try {
await contentPasswordRecoveryApi.resetPassword(
props.genealogyId,
props.resourceType,
props.resourceId,
payload,
{ requestController: resetController },
);
if (!componentActive || !props.visible) return;
emit("complete", payload.newPassword);
emit("close");
resetForm();
} catch (error) {
const isOutcomeUnknown = resetGuard.recordFailure(resetAttempt, error);
if (!componentActive || !props.visible) return;
if (isOutcomeUnknown) {
emit("complete", payload.newPassword);
formError.value = "重置结果待确认,请关闭窗口后尝试使用新密码解锁,不要重复提交。";
return;
}
if (isRequestCancelled(error)) return;
formError.value = getRequestErrorMessage(error, "内容密码重置失败,请检查验证码后重试。");
} finally {
if (componentActive) resetting.value = false;
}
};
const requestClose = () => {
if (!isBusy.value) emit("close");
};
watch(
() => props.visible,
(visible) => {
if (!visible) {
capabilityController.abort();
codeController.abort();
resetController.abort();
stopCooldown();
resetForm();
capabilityState.value = "idle";
return;
}
resetForm();
void loadCapability();
},
);
onUnmounted(() => {
componentActive = false;
stopCooldown();
capabilityController.abort();
codeController.abort();
resetController.abort();
});
</script>
<style scoped lang="scss">
.recovery-dialog { width: 100%; margin-top: 16rpx; text-align: left; }
.recovery-dialog__copy,
.recovery-dialog__security,
.recovery-dialog__state text,
.recovery-dialog__error,
.recovery-dialog__notice { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.recovery-dialog input { width: 100%; min-height: var(--app-touch-min); margin-top: 16rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(142, 95, 41, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .68); }
.recovery-dialog__code-row { display: flex; align-items: center; margin-top: 16rpx; gap: 12rpx; }
.recovery-dialog__code-row input { min-width: 0; flex: 1; margin-top: 0; }
.recovery-dialog__code-row .app-button { width: auto; flex: 0 0 auto; }
.recovery-dialog > .app-button { margin-top: 20rpx; }
.recovery-dialog__state .app-button { width: auto; margin-top: 16rpx; }
.recovery-dialog__error { margin-top: 14rpx; color: $brand-red; }
.recovery-dialog__notice { margin-top: 14rpx; color: #426b58; }
.recovery-dialog__security { margin-top: 18rpx; padding-top: 14rpx; border-top: 1rpx solid rgba(142, 95, 41, .18); font-size: clamp(12px, 19rpx, 14px); }
</style>
+2 -1
View File
@@ -7,7 +7,7 @@
<image
class="module-page-background__image"
:src="source"
mode="widthFix"
mode="aspectFill"
/>
</view>
</template>
@@ -48,6 +48,7 @@ const source = computed(() => sources[props.module] || sources.profile);
bottom: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.36;
}
.module-page-background--family .module-page-background__image {
+208
View File
@@ -0,0 +1,208 @@
<template>
<view v-if="visible" class="vertical-video-viewer">
<view class="vertical-video-viewer__header">
<button aria-label="关闭上下滑动播放" @click="close">返回</button>
<text>{{ title }}</text>
<text>{{ activeIndex + 1 }}/{{ videos.length }}</text>
</view>
<swiper
class="vertical-video-viewer__swiper"
vertical
:current="activeIndex"
:duration="260"
@change="changeVideo"
>
<swiper-item v-for="(video, index) in videos" :key="video.id">
<view class="vertical-video-viewer__slide">
<video
:id="videoElementId(video)"
class="vertical-video-viewer__player"
:src="video.videoFile.accessUrl"
:poster="video.coverFile?.accessUrl || ''"
:autoplay="visible && index === activeIndex"
loop
controls
object-fit="contain"
/>
<view class="vertical-video-viewer__summary">
<text class="vertical-video-viewer__title">{{ video.title }}</text>
<text v-if="video.description" class="vertical-video-viewer__description">{{ video.description }}</text>
<view class="vertical-video-viewer__actions">
<button :disabled="actionBusy" @click="requestLike(video)">
{{ video.likedByCurrentUser ? "已赞" : "点赞" }} {{ video.likeCount || 0 }}
</button>
<button :disabled="actionBusy" @click="requestComments(video)">
评论 {{ video.commentCount || 0 }}
</button>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
</template>
<script setup>
import { nextTick, ref, watch } from "vue";
const props = defineProps({
visible: { type: Boolean, default: false },
videos: { type: Array, default: () => [] },
initialVideoId: { type: [String, Number], default: "" },
title: { type: String, default: "视频播放" },
actionBusy: { type: Boolean, default: false },
});
const emit = defineEmits(["close", "like", "comments"]);
const activeIndex = ref(0);
const videoElementId = (video) => `vertical-video-${String(video.id)}`;
const pauseVideo = (index) => {
const video = props.videos[index];
if (!video) return;
uni.createVideoContext(videoElementId(video))?.pause();
};
const playVideo = (index) => {
const video = props.videos[index];
if (!video) return;
uni.createVideoContext(videoElementId(video))?.play();
};
const resolveInitialIndex = () => {
const requestedId = String(props.initialVideoId || "");
const requestedIndex = props.videos.findIndex(
(video) => String(video.id) === requestedId,
);
return requestedIndex >= 0 ? requestedIndex : 0;
};
watch(
() => props.visible,
async (visible) => {
if (!visible) {
pauseVideo(activeIndex.value);
return;
}
activeIndex.value = resolveInitialIndex();
await nextTick();
playVideo(activeIndex.value);
},
);
const changeVideo = async (event) => {
const nextIndex = Number(event?.detail?.current ?? 0);
if (!Number.isInteger(nextIndex) || nextIndex < 0 || nextIndex >= props.videos.length) return;
pauseVideo(activeIndex.value);
activeIndex.value = nextIndex;
await nextTick();
playVideo(activeIndex.value);
};
const close = () => {
pauseVideo(activeIndex.value);
emit("close");
};
const requestLike = (video) => emit("like", video);
const requestComments = (video) => {
pauseVideo(activeIndex.value);
emit("comments", video);
};
</script>
<style scoped lang="scss">
.vertical-video-viewer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
flex-direction: column;
color: #fff;
background: #080808;
}
.vertical-video-viewer__header {
display: grid;
min-height: 88rpx;
padding: calc(12rpx + env(safe-area-inset-top)) 24rpx 12rpx;
grid-template-columns: 120rpx 1fr 120rpx;
align-items: center;
background: rgba(0, 0, 0, 0.82);
text-align: center;
}
.vertical-video-viewer__header button,
.vertical-video-viewer__actions button {
min-height: 64rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
border-radius: 32rpx;
color: #fff;
background: rgba(0, 0, 0, 0.42);
font-size: 26rpx;
line-height: 1;
}
.vertical-video-viewer__header button {
padding: 0 20rpx;
justify-self: start;
}
.vertical-video-viewer__header text:nth-child(2) {
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.vertical-video-viewer__header text:last-child {
justify-self: end;
color: rgba(255, 255, 255, 0.72);
}
.vertical-video-viewer__swiper,
.vertical-video-viewer__slide {
min-height: 0;
flex: 1;
}
.vertical-video-viewer__slide {
position: relative;
display: flex;
height: 100%;
align-items: center;
background: #080808;
}
.vertical-video-viewer__player {
width: 100%;
height: 100%;
}
.vertical-video-viewer__summary {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 80rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
background: linear-gradient(transparent, rgba(0, 0, 0, 0.86));
pointer-events: none;
}
.vertical-video-viewer__summary text {
display: block;
}
.vertical-video-viewer__title {
font-size: 34rpx;
font-weight: 700;
}
.vertical-video-viewer__description {
display: -webkit-box !important;
overflow: hidden;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 26rpx;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.vertical-video-viewer__actions {
display: flex;
margin-top: 22rpx;
gap: 16rpx;
pointer-events: auto;
}
.vertical-video-viewer__actions button {
min-width: 150rpx;
padding: 0 24rpx;
}
@media (prefers-reduced-motion: reduce) {
.vertical-video-viewer__swiper { scroll-behavior: auto; }
}
</style>
+7 -1
View File
@@ -374,12 +374,18 @@ onUnmounted(() => {
min-width: 0;
flex: 1;
}
.invitation-manager__row .app-button {
width: auto;
min-width: 120rpx;
flex: 0 0 auto;
}
.invitation-manager__row text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 28rpx, 21px);
font-weight: 700;
overflow-wrap: anywhere;
overflow-wrap: break-word;
word-break: break-word;
}
.issued-invitation__token {
box-sizing: border-box;
+2 -1
View File
@@ -3,7 +3,7 @@
<image
class="genealogy-page-background__art"
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
mode="widthFix"
mode="aspectFill"
/>
</view>
</template>
@@ -25,6 +25,7 @@
left: 0;
display: block;
width: 100%;
height: 100%;
opacity: 0.28;
}
+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;
+79 -38
View File
@@ -22,7 +22,7 @@
label="新建证件档案"
@click="openDocumentCreate"
/>
<text v-if="!documents.length" class="document-dialog-empty">这位成员还没有可查看的重要证件</text>
<text v-if="!documents.length" class="document-dialog-empty">{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}</text>
<view
v-for="documentSummary in documents"
:key="documentSummary.documentId"
@@ -34,7 +34,7 @@
<view>
<text>{{ documentSummary.documentTitle }}</text>
<text
>{{ documentTypeLabel(documentSummary.documentType) }}{{
>{{ documentSummary.lineagePersonName ? `${documentSummary.lineagePersonName} · ` : "" }}{{ documentTypeLabel(documentSummary.documentType) }}{{
documentSummary.maskedIdentifier
? ` · ${documentSummary.maskedIdentifier}`
: ""
@@ -56,6 +56,7 @@
<view class="document-unlock-action" role="button" aria-label="解锁重要证件" @click="unlockDocument">
<text>{{ documentState === 'submitting' ? '正在解锁…' : '解锁并查看' }}</text>
</view>
<button class="document-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码</button>
</template>
<template v-else-if="documentDialogMode === 'detail' && selectedDocument">
<view class="document-detail-row"><text>证件类型</text><text>{{ documentTypeLabel(selectedDocument.documentType) }}</text></view>
@@ -71,12 +72,12 @@
<text>{{ resourceUsageLabel(resource.usageType) }}</text>
<view class="document-resource-actions">
<button @click="openDocumentResource(resource)">查看</button>
<button v-if="selectedDocument.canEdit && member?.canManageDocuments" @click="replaceDocumentResource(resource)">替换</button>
<button v-if="selectedDocument.canEdit && member?.canManageDocuments" @click="requestDeleteDocumentResource(resource)">删除</button>
<button v-if="selectedDocument.canEdit" @click="replaceDocumentResource(resource)">替换</button>
<button v-if="selectedDocument.canEdit" @click="requestDeleteDocumentResource(resource)">删除</button>
</view>
</view>
<text v-if="documentError" class="document-dialog-error">{{ documentError }}</text>
<view v-if="member?.canManageDocuments" class="document-manage-actions">
<view v-if="selectedDocument.canEdit || selectedDocument.canDelete" class="document-manage-actions">
<AppButton v-if="selectedDocument.canEdit" compact label="编辑资料" @click="openDocumentEdit" />
<AppButton v-if="selectedDocument.canEdit" compact type="secondary" label="添加文件" @click="addDocumentResource" />
<AppButton
@@ -164,6 +165,15 @@
@confirm="disableDocumentPassword"
@cancel="documentPasswordDisableVisible = false"
/>
<ContentPasswordRecoveryDialog
:visible="passwordRecoveryVisible"
:genealogy-id="genealogyId"
resource-type="PERSON_DOCUMENT"
:resource-id="String(selectedDocument?.documentId || '')"
@close="passwordRecoveryVisible = false"
@complete="completePasswordRecovery"
@busy-change="passwordRecoveryBusy = $event"
/>
</view>
</template>
@@ -172,11 +182,12 @@ import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
PERSON_DOCUMENT_RESOURCE_USAGE,
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS,
PERSON_DOCUMENT_TYPE_OPTIONS as documentTypeOptions
PERSON_DOCUMENT_RESOURCE_USAGE_LABELS
} from "@/services/api/person-document-contract.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import {
createRequestController,
isRequestCancelled
@@ -188,7 +199,7 @@ import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guar
const props = defineProps({
genealogyId: { type: String, required: true },
personId: { type: String, required: true },
personId: { type: String, default: "" },
member: { type: Object, default: null },
});
const emit = defineEmits(["busy-change", "transient-change"]);
@@ -197,6 +208,7 @@ const documentDialogVisible = ref(false);
const documentDialogMode = ref("list");
const documentState = ref("idle");
const documents = ref([]);
const documentTypeOptions = ref([]);
const selectedDocument = ref(null);
const documentPassword = ref("");
const documentPasswordConfirm = ref("");
@@ -205,6 +217,8 @@ const documentError = ref("");
const documentDeleteVisible = ref(false);
const documentResourceDeleteTarget = ref(null);
const documentPasswordDisableVisible = ref(false);
const passwordRecoveryVisible = ref(false);
const passwordRecoveryBusy = ref(false);
const documentUploadReceipt = ref(null);
const documentForm = reactive({
documentType: "id_card",
@@ -213,6 +227,7 @@ const documentForm = reactive({
description: "",
});
const documentListRequestController = createRequestController();
const documentTypeRequestController = createRequestController();
const documentDetailRequestController = createRequestController();
const documentDraftUploadRequestController = createRequestController();
const documentSaveRequestController = createRequestController();
@@ -239,7 +254,9 @@ const documentDialogTitle = computed(() => {
case "detail":
return selectedDocument.value?.documentTitle || "证件详情";
default:
return `${member.value?.name || "成员"}的重要证件`;
return props.personId
? `${member.value?.name || "成员"}的重要证件`
: "家谱重要证件";
}
});
const documentDialogMessage = computed(() => {
@@ -255,20 +272,16 @@ const documentDialogMessage = computed(() => {
}
});
const documentTypeLabel = (documentType) =>
({
id_card: "身份证",
household_register: "户口簿",
birth_certificate: "出生证明",
marriage_certificate: "结婚证",
other: "其他证件",
})[documentType] || "重要证件";
documentTypeOptions.value.find((option) => option.value === documentType)?.label ||
documents.value.find((document) => document.documentType === documentType)?.documentTypeLabel ||
"重要证件";
const documentTypeLabels = computed(() =>
documentTypeOptions.map((documentType) => documentType.label),
documentTypeOptions.value.map((documentType) => documentType.label),
);
const documentTypeIndex = computed(() =>
Math.max(
0,
documentTypeOptions.findIndex(
documentTypeOptions.value.findIndex(
(documentType) => documentType.value === documentForm.documentType,
),
),
@@ -280,11 +293,12 @@ const hasTransient = computed(() =>
documentDialogVisible.value ||
documentDeleteVisible.value ||
documentResourceDeleteTarget.value ||
documentPasswordDisableVisible.value,
documentPasswordDisableVisible.value ||
passwordRecoveryVisible.value,
),
);
const isBusy = computed(() =>
documentState.value === "loading" || documentState.value === "submitting",
documentState.value === "loading" || documentState.value === "submitting" || passwordRecoveryBusy.value,
);
const shouldIgnoreDocumentFailure = (error) => !isActive || isRequestCancelled(error);
const isCurrentPersonContext = (personId, activeWorkflow) =>
@@ -301,6 +315,7 @@ watch(isBusy, (value) => emit("busy-change", value), { immediate: true });
const resetDocumentWorkflow = () => {
workflowVersion += 1;
documentListRequestController.abort();
documentTypeRequestController.abort();
documentDetailRequestController.abort();
documentDraftUploadRequestController.abort();
documentSaveRequestController.abort();
@@ -315,6 +330,7 @@ const resetDocumentWorkflow = () => {
documentDeleteVisible.value = false;
documentResourceDeleteTarget.value = null;
documentPasswordDisableVisible.value = false;
passwordRecoveryVisible.value = false;
documentDialogMode.value = "list";
documentState.value = "idle";
documents.value = [];
@@ -336,13 +352,22 @@ const open = async () => {
selectedDocument.value = null;
documentAccessToken.value = "";
try {
const documentRows = await personDocumentApi.getPersonDocuments(
props.genealogyId,
{ lineagePersonId: props.personId },
{ requestController: documentListRequestController },
);
const documentQuery = props.personId
? { lineagePersonId: props.personId }
: {};
const [documentRows, typeOptions] = await Promise.all([
personDocumentApi.getPersonDocuments(
props.genealogyId,
documentQuery,
{ requestController: documentListRequestController },
),
businessDictionaryApi.getBusinessDictionaryOptions("gen_person_document_type", {
requestController: documentTypeRequestController,
}),
]);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
documents.value = documentRows;
documentTypeOptions.value = typeOptions;
documentState.value = "ready";
} catch (error) {
if (
@@ -363,9 +388,9 @@ const showDocumentList = () => {
documentError.value = "";
};
const openDocumentCreate = () => {
if (!member.value?.canCreateDocument) return;
if (!member.value?.canCreateDocument || !documentTypeOptions.value.length) return;
Object.assign(documentForm, {
documentType: "id_card",
documentType: documentTypeOptions.value.find((option) => option.default)?.value || documentTypeOptions.value[0].value,
documentTitle: "",
maskedIdentifier: "",
description: "",
@@ -376,19 +401,23 @@ const openDocumentCreate = () => {
documentError.value = "";
};
const openDocumentEdit = () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
const currentTypeAvailable = documentTypeOptions.value.some(
(option) => option.value === selectedDocument.value.documentType,
);
Object.assign(documentForm, {
documentType: selectedDocument.value.documentType,
documentType: currentTypeAvailable ? selectedDocument.value.documentType : "",
documentTitle: selectedDocument.value.documentTitle,
maskedIdentifier: selectedDocument.value.maskedIdentifier,
description: selectedDocument.value.description,
});
documentDialogMode.value = "edit";
documentState.value = "ready";
documentError.value = "";
documentError.value = currentTypeAvailable ? "" : "原证件类型已停用,请重新选择。";
};
const selectDocumentType = (event) => {
documentForm.documentType = documentTypeOptions[Number(event.detail.value)]?.value || "id_card";
documentForm.documentType = documentTypeOptions.value[Number(event.detail.value)]?.value || "";
documentError.value = "";
};
const uploadDocumentImage = async () => {
if (documentState.value === "submitting") return;
@@ -527,7 +556,7 @@ const loadDocumentDetail = async (documentId, accessToken) => {
}
};
const updateDocument = async () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
if (!documentForm.documentTitle.trim()) {
documentError.value = "请填写证件名称。";
return;
@@ -535,13 +564,14 @@ const updateDocument = async () => {
documentState.value = "submitting";
documentError.value = "";
const documentPersonId = props.personId;
const targetPersonId = props.personId || selectedDocument.value.lineagePersonId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
try {
const updatedDocument = await personDocumentApi.updatePersonDocument(
props.genealogyId,
documentId,
{ lineagePersonId: documentPersonId, ...documentForm },
{ lineagePersonId: targetPersonId, ...documentForm },
{ requestController: documentSaveRequestController },
);
if (!isCurrentDocumentContext(documentPersonId, documentId, activeWorkflow)) return;
@@ -600,6 +630,10 @@ const unlockDocument = async () => {
documentError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
}
};
const completePasswordRecovery = (newPassword) => {
documentPassword.value = newPassword;
documentError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openDocumentResource = async (resource) => {
if (!selectedDocument.value) return;
const documentPersonId = props.personId;
@@ -651,7 +685,7 @@ const openDocumentResource = async (resource) => {
}
};
const addDocumentResource = async () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
const documentPersonId = props.personId;
const documentId = selectedDocument.value.documentId;
const activeWorkflow = workflowVersion;
@@ -711,7 +745,7 @@ const addDocumentResource = async () => {
}
};
const replaceDocumentResource = async (resource) => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments || documentState.value === "submitting") return;
if (!selectedDocument.value?.canEdit || documentState.value === "submitting") return;
documentState.value = "submitting";
documentError.value = "";
const documentPersonId = props.personId;
@@ -753,7 +787,7 @@ const replaceDocumentResource = async (resource) => {
}
};
const requestDeleteDocumentResource = (resource) => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
documentResourceDeleteTarget.value = resource;
};
const confirmDeleteDocumentResource = async () => {
@@ -785,7 +819,7 @@ const confirmDeleteDocumentResource = async () => {
}
};
const openDocumentPassword = () => {
if (!selectedDocument.value?.canEdit || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canEdit) return;
documentPassword.value = "";
documentPasswordConfirm.value = "";
documentError.value = "";
@@ -861,7 +895,7 @@ const handleDocumentConfirm = () => {
resetDocumentWorkflow();
};
const requestDeleteDocument = () => {
if (!selectedDocument.value?.canDelete || !member.value?.canManageDocuments) return;
if (!selectedDocument.value?.canDelete) return;
documentDeleteVisible.value = true;
};
const confirmDeleteDocument = async () => {
@@ -891,6 +925,11 @@ const confirmDeleteDocument = async () => {
}
};
const closeTransient = () => {
if (passwordRecoveryVisible.value) {
if (passwordRecoveryBusy.value) return false;
passwordRecoveryVisible.value = false;
return true;
}
if (documentResourceDeleteTarget.value) {
documentResourceDeleteTarget.value = null;
return true;
@@ -973,6 +1012,8 @@ onUnmounted(() => {
font-size: clamp(14px, 22rpx, 17px);
}
.document-upload-button::after { border: 0; }
.document-recovery-link { display: block; min-height: var(--app-touch-min); margin: 6rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
.document-recovery-link::after { border: 0; }
.document-manage-actions {
display: flex;
flex-wrap: wrap;